From f2552df7445b0c6fb6230f90072f6b73c6647e25 Mon Sep 17 00:00:00 2001 From: Clifford Tawiah Date: Mon, 9 Nov 2026 13:45:06 -0500 Subject: [PATCH] feat(sync): add the TypeScript and JavaScript adapters Add the TypeScript and JavaScript adapters with the same static rules as Python. Add the package that registers every shipped format, and the conformance tests that run every format through the shared core. Co-Authored-By: Claude Fable 5.1 --- .../sync/adapter/adaptertest/adaptertest.go | 144 + internal/sync/adapter/all/all.go | 10 + internal/sync/adapter/all/code_test.go | 284 ++ internal/sync/adapter/all/conformance_test.go | 358 +++ internal/sync/adapter/all/ecmascript_test.go | 244 ++ internal/sync/adapter/all/helpers_test.go | 107 + internal/sync/adapter/all/markdown_test.go | 206 ++ internal/sync/adapter/all/python_test.go | 518 ++++ internal/sync/adapter/all/structured_test.go | 544 ++++ .../sync/adapter/ecmascript/ecmascript.go | 2279 ++++++++++++++ .../adapter/ecmascript/ecmascript_test.go | 2653 +++++++++++++++++ .../sync/adapter/ecmascript/frameworks.go | 365 +++ .../adapter/ecmascript/testdata/frameworks.ts | 90 + .../ecmascript/testdata/imports/src/agent.ts | 8 + .../testdata/imports/src/prompts.ts | 1 + .../adapter/ecmascript/testdata/langchain.ts | 23 + .../adapter/ecmascript/testdata/server.js | 12 + .../adapter/ecmascript/testdata/server.ts | 21 + .../sync/adapter/python/testdata/agents.py | 22 + .../python/testdata/frameworks_tier1.py | 81 + .../python/testdata/frameworks_tier2.py | 83 + .../python/testdata/imports/app/agent.py | 8 + .../python/testdata/imports/app/prompts.py | 1 + .../python/testdata/langchain_prompts.py | 37 + .../sync/adapter/python/testdata/messages.py | 11 + .../sync/adapter/python/testdata/settings.py | 44 + 26 files changed, 8154 insertions(+) create mode 100644 internal/sync/adapter/adaptertest/adaptertest.go create mode 100644 internal/sync/adapter/all/all.go create mode 100644 internal/sync/adapter/all/code_test.go create mode 100644 internal/sync/adapter/all/conformance_test.go create mode 100644 internal/sync/adapter/all/ecmascript_test.go create mode 100644 internal/sync/adapter/all/helpers_test.go create mode 100644 internal/sync/adapter/all/markdown_test.go create mode 100644 internal/sync/adapter/all/python_test.go create mode 100644 internal/sync/adapter/all/structured_test.go create mode 100644 internal/sync/adapter/ecmascript/ecmascript.go create mode 100644 internal/sync/adapter/ecmascript/ecmascript_test.go create mode 100644 internal/sync/adapter/ecmascript/frameworks.go create mode 100644 internal/sync/adapter/ecmascript/testdata/frameworks.ts create mode 100644 internal/sync/adapter/ecmascript/testdata/imports/src/agent.ts create mode 100644 internal/sync/adapter/ecmascript/testdata/imports/src/prompts.ts create mode 100644 internal/sync/adapter/ecmascript/testdata/langchain.ts create mode 100644 internal/sync/adapter/ecmascript/testdata/server.js create mode 100644 internal/sync/adapter/ecmascript/testdata/server.ts create mode 100644 internal/sync/adapter/python/testdata/agents.py create mode 100644 internal/sync/adapter/python/testdata/frameworks_tier1.py create mode 100644 internal/sync/adapter/python/testdata/frameworks_tier2.py create mode 100644 internal/sync/adapter/python/testdata/imports/app/agent.py create mode 100644 internal/sync/adapter/python/testdata/imports/app/prompts.py create mode 100644 internal/sync/adapter/python/testdata/langchain_prompts.py create mode 100644 internal/sync/adapter/python/testdata/messages.py create mode 100644 internal/sync/adapter/python/testdata/settings.py diff --git a/internal/sync/adapter/adaptertest/adaptertest.go b/internal/sync/adapter/adaptertest/adaptertest.go new file mode 100644 index 000000000..2107c6b8c --- /dev/null +++ b/internal/sync/adapter/adaptertest/adaptertest.go @@ -0,0 +1,144 @@ +// Package adaptertest holds the helpers that adapter tests share: bundles, +// fixtures, edit application with byte identity checks, and projection text +// access. +package adaptertest + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "path/filepath" + "slices" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" + "github.com/launchdarkly/ldcli/internal/sync/adapter" +) + +// Bundle returns a bundle with one primary document and no dependencies. +func Bundle(path string, data []byte) adapter.SourceBundle { + return adapter.SourceBundle{Primary: adapter.Document{Path: path, Data: data}} +} + +// Fixture reads one file below the testdata directory of the test package. +func Fixture(t *testing.T, name string) []byte { + t.Helper() + data, err := os.ReadFile(filepath.Join("testdata", filepath.FromSlash(name))) + require.NoError(t, err) + return data +} + +// Materialize writes fixture data to realPath in a temporary directory. Tests +// use it for files whose real name activates a coding tool, such as SKILL.md. +func Materialize(t *testing.T, data []byte, realPath string) string { + t.Helper() + path := filepath.Join(t.TempDir(), filepath.FromSlash(realPath)) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, data, 0o644)) + return path +} + +// ApplyEdits applies edits to source from the highest offset to the lowest. +func ApplyEdits(source []byte, edits ...adapter.TextEdit) []byte { + ordered := slices.Clone(edits) + slices.SortFunc(ordered, func(left, right adapter.TextEdit) int { return right.Start - left.Start }) + result := bytes.Clone(source) + for _, edit := range ordered { + result = slices.Concat(result[:edit.Start], edit.Replacement, result[edit.End:]) + } + return result +} + +// ApplyChecked applies edits and proves that every byte outside the edits is +// unchanged and that each edit expected the bytes that it replaced. +func ApplyChecked(t *testing.T, source []byte, edits []adapter.TextEdit) []byte { + t.Helper() + for _, edit := range edits { + require.Equal(t, edit.Expected, source[edit.Start:edit.End]) + } + updated := ApplyEdits(source, edits...) + ascending := slices.Clone(edits) + slices.SortFunc(ascending, func(left, right adapter.TextEdit) int { return left.Start - right.Start }) + beforeOffset, afterOffset := 0, 0 + for _, edit := range ascending { + length := edit.Start - beforeOffset + require.Equal(t, source[beforeOffset:edit.Start], updated[afterOffset:afterOffset+length]) + beforeOffset = edit.End + afterOffset += length + len(edit.Replacement) + } + require.Equal(t, source[beforeOffset:], updated[afterOffset:]) + return updated +} + +// DirectEdits returns the edits of an update and fails if a field is not direct. +func DirectEdits(t *testing.T, result adapter.UpdateResult) []adapter.TextEdit { + t.Helper() + var edits []adapter.TextEdit + for field, update := range result.Fields { + require.Equal(t, adapter.CapabilityDirect, update.Capability, field) + edits = append(edits, update.Edits...) + } + require.NotEmpty(t, edits) + return edits +} + +// PrimaryText returns the main text of a projection: the instructions of a +// variation, or the description of a tool or a skill. +func PrimaryText(t *testing.T, projection syncdomain.Projection) string { + t.Helper() + switch value := projection.(type) { + case syncdomain.VariationProjection: + require.NotNil(t, value.Instructions) + return *value.Instructions + case syncdomain.ToolProjection: + require.NotNil(t, value.Description) + return *value.Description + case syncdomain.SkillProjection: + require.NotNil(t, value.Description) + return *value.Description + default: + t.Fatalf("unsupported projection %T", projection) + return "" + } +} + +// WithText returns a copy of the projection with new main text. +func WithText(projection syncdomain.Projection, text string) syncdomain.Projection { + switch value := projection.(type) { + case syncdomain.VariationProjection: + value.Instructions = &text + value.Messages = nil + return value + case syncdomain.ToolProjection: + value.Description = &text + return value + case syncdomain.SkillProjection: + value.Description = &text + return value + default: + return projection + } +} + +// ModelParameterTarget returns the map key of one model parameter. +func ModelParameterTarget(key string) string { + encoded, _ := json.Marshal(key) + return fmt.Sprintf("model.parameters[%s]", encoded) +} + +// AssertJSONEqual compares two values by their JSON encoding. +func AssertJSONEqual(t *testing.T, expected, actual any) { + t.Helper() + expectedJSON, err := json.Marshal(expected) + require.NoError(t, err) + actualJSON, err := json.Marshal(actual) + require.NoError(t, err) + assert.JSONEq(t, string(expectedJSON), string(actualJSON)) +} + +// StringPointer returns a pointer to value. +func StringPointer(value string) *string { return &value } diff --git a/internal/sync/adapter/all/all.go b/internal/sync/adapter/all/all.go new file mode 100644 index 000000000..da82f9b8a --- /dev/null +++ b/internal/sync/adapter/all/all.go @@ -0,0 +1,10 @@ +// Package all registers every shipped source format. A host imports it for +// its side effect. To hold a format back from a release, remove its import. +package all + +import ( + _ "github.com/launchdarkly/ldcli/internal/sync/adapter/ecmascript" + _ "github.com/launchdarkly/ldcli/internal/sync/adapter/markdown" + _ "github.com/launchdarkly/ldcli/internal/sync/adapter/python" + _ "github.com/launchdarkly/ldcli/internal/sync/adapter/structured" +) diff --git a/internal/sync/adapter/all/code_test.go b/internal/sync/adapter/all/code_test.go new file mode 100644 index 000000000..0264dfaac --- /dev/null +++ b/internal/sync/adapter/all/code_test.go @@ -0,0 +1,284 @@ +package all + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" + "github.com/launchdarkly/ldcli/internal/sync/adapter" + "github.com/launchdarkly/ldcli/internal/sync/adapter/adaptertest" +) + +func TestFrameworkInspectorsReadAndWriteStaticFields(t *testing.T) { + tests := []frameworkCase{ + {name: "OpenAI Python responses", fixture: "python/frameworks_tier1.py", format: "python", collection: "prompts", id: "openai_response", sourcePath: "instructions", want: "Answer in one paragraph.", kind: syncdomain.KindVariation}, + {name: "Anthropic Python", fixture: "python/frameworks_tier1.py", format: "python", collection: "prompts", id: "anthropic_message", sourcePath: "system", want: "Follow the support policy.", kind: syncdomain.KindVariation}, + {name: "OpenAI Agents Python", fixture: "python/frameworks_tier1.py", format: "python", collection: "agents", id: "support_agent", sourcePath: "instructions", want: "Ask for an order ID.", kind: syncdomain.KindVariation}, + {name: "LangGraph", fixture: "python/frameworks_tier1.py", format: "python", collection: "agents", id: "react_agent", sourcePath: "prompt", want: "Use the support tools.", kind: syncdomain.KindVariation}, + {name: "OpenAI function tool", fixture: "python/frameworks_tier1.py", format: "python", collection: "tools", id: "lookup_order", sourcePath: "description", want: "Look up one order.", kind: syncdomain.KindTool}, + {name: "LangChain tool", fixture: "python/frameworks_tier1.py", format: "python", collection: "tools", id: "refund_order", sourcePath: "description", want: "Refund one order.", kind: syncdomain.KindTool}, + {name: "StructuredTool", fixture: "python/frameworks_tier1.py", format: "python", collection: "tools", id: "structured", sourcePath: "description", want: "Look up one order with a structured tool.", kind: syncdomain.KindTool}, + {name: "Agent as tool", fixture: "python/frameworks_tier1.py", format: "python", collection: "tools", id: "support_tool", sourcePath: "tool_description", want: "Ask the support agent.", kind: syncdomain.KindTool}, + {name: "FastMCP tool", fixture: "python/frameworks_tier1.py", format: "python", collection: "tools", id: "mcp_lookup", sourcePath: "description", want: "Look up one order through MCP.", kind: syncdomain.KindTool}, + {name: "CrewAI agent", fixture: "python/frameworks_tier2.py", format: "python", collection: "agents", id: "crew_researcher", sourcePath: "goal", want: "Find the relevant support policy.", kind: syncdomain.KindVariation}, + {name: "CrewAI task", fixture: "python/frameworks_tier2.py", format: "python", collection: "prompts", id: "crew_task", sourcePath: "description", want: "Research the refund request.", kind: syncdomain.KindVariation}, + {name: "Pydantic AI", fixture: "python/frameworks_tier2.py", format: "python", collection: "agents", id: "pydantic_agent", sourcePath: "instructions", want: "Ask for an order ID.", kind: syncdomain.KindVariation}, + {name: "Google ADK", fixture: "python/frameworks_tier2.py", format: "python", collection: "agents", id: "adk_agent", sourcePath: "instruction", want: "Ask for an order ID.", kind: syncdomain.KindVariation}, + {name: "Google LlmAgent", fixture: "python/frameworks_tier2.py", format: "python", collection: "agents", id: "llm_agent", sourcePath: "instruction", want: "Explain refunds.", kind: syncdomain.KindVariation}, + {name: "AWS Strands", fixture: "python/frameworks_tier2.py", format: "python", collection: "agents", id: "strands_agent", sourcePath: "system_prompt", want: "Follow the support policy.", kind: syncdomain.KindVariation}, + {name: "AutoGen", fixture: "python/frameworks_tier2.py", format: "python", collection: "agents", id: "autogen_agent", sourcePath: "system_message", want: "Ask for an order ID.", kind: syncdomain.KindVariation}, + {name: "LlamaIndex prompt", fixture: "python/frameworks_tier2.py", format: "python", collection: "prompts", id: "llama_prompt", sourcePath: "template", want: "Explain {{topic}}.", kind: syncdomain.KindVariation}, + {name: "LlamaIndex tool", fixture: "python/frameworks_tier2.py", format: "python", collection: "tools", id: "llama_tool", sourcePath: "description", want: "Look up one order.", kind: syncdomain.KindTool}, + {name: "LlamaIndex agent", fixture: "python/frameworks_tier2.py", format: "python", collection: "agents", id: "llama_agent", sourcePath: "system_prompt", want: "Use the support tools.", kind: syncdomain.KindVariation}, + {name: "Semantic Kernel agent", fixture: "python/frameworks_tier2.py", format: "python", collection: "agents", id: "semantic_agent", sourcePath: "instructions", want: "Ask for an order ID.", kind: syncdomain.KindVariation}, + {name: "Semantic Kernel tool", fixture: "python/frameworks_tier2.py", format: "python", collection: "tools", id: "semantic_lookup", sourcePath: "description", want: "Look up one order with Semantic Kernel.", kind: syncdomain.KindTool}, + {name: "Pydantic AI tool", fixture: "python/frameworks_tier2.py", format: "python", collection: "tools", id: "pydantic_lookup", sourcePath: "description", want: "Look up one order with Pydantic AI.", kind: syncdomain.KindTool}, + {name: "Strands tool", fixture: "python/frameworks_tier2.py", format: "python", collection: "tools", id: "strands_lookup", sourcePath: "description", want: "Look up one order with Strands.", kind: syncdomain.KindTool}, + {name: "OpenAI TypeScript responses", fixture: "typescript/frameworks.ts", format: "typescript", collection: "prompts", id: "openaiResponse", sourcePath: "instructions", want: "Answer in one paragraph.", kind: syncdomain.KindVariation}, + {name: "Anthropic TypeScript", fixture: "typescript/frameworks.ts", format: "typescript", collection: "prompts", id: "anthropicMessage", sourcePath: "system", want: "Follow the support policy.", kind: syncdomain.KindVariation}, + {name: "Vercel generateText", fixture: "typescript/frameworks.ts", format: "typescript", collection: "prompts", id: "generated", sourcePath: "system", want: "Follow the support policy.", kind: syncdomain.KindVariation}, + {name: "Vercel tool", fixture: "typescript/frameworks.ts", format: "typescript", collection: "tools", id: "vercelLookup", sourcePath: "description", want: "Look up one order.", kind: syncdomain.KindTool}, + {name: "OpenAI Agents JS", fixture: "typescript/frameworks.ts", format: "typescript", collection: "agents", id: "supportAgent", sourcePath: "instructions", want: "Ask for an order ID.", kind: syncdomain.KindVariation}, + {name: "OpenAI Agents JS tool", fixture: "typescript/frameworks.ts", format: "typescript", collection: "tools", id: "openaiLookup", sourcePath: "description", want: "Look up one order with OpenAI Agents.", kind: syncdomain.KindTool}, + {name: "LangChain JS tool", fixture: "typescript/frameworks.ts", format: "typescript", collection: "tools", id: "langchainLookup", sourcePath: "description", want: "Look up one order with LangChain.", kind: syncdomain.KindTool}, + {name: "MCP TypeScript tool", fixture: "typescript/frameworks.ts", format: "typescript", collection: "tools", id: "mcp_lookup", sourcePath: "description", want: "Look up one order through MCP.", kind: syncdomain.KindTool}, + {name: "MCP TypeScript prompt", fixture: "typescript/frameworks.ts", format: "typescript", collection: "prompts", id: "mcp_prompt", sourcePath: "description", want: "Build one MCP prompt.", kind: syncdomain.KindVariation}, + {name: "Mastra agent", fixture: "typescript/frameworks.ts", format: "typescript", collection: "agents", id: "mastraAgent", sourcePath: "instructions", want: "Ask for an order ID with Mastra.", kind: syncdomain.KindVariation}, + {name: "Mastra tool", fixture: "typescript/frameworks.ts", format: "typescript", collection: "tools", id: "mastraLookup", sourcePath: "description", want: "Look up one order with Mastra.", kind: syncdomain.KindTool}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + source := fixtureFile(t, test.fixture) + field := "prompt" + id := syncdomain.VariationID("project", "config", "default") + if test.kind == syncdomain.KindTool { + field = "description" + id = syncdomain.ResourceID{Kind: syncdomain.KindTool, ProjectKey: "project", LookupKey: test.id} + } + reference := syncdomain.Reference{ + File: test.fixture, Format: test.format, + Select: test.collection + `[id="` + test.id + `"]`, + Map: map[string]string{field: test.sourcePath}, + } + projection, err := adapter.ParseSource(adaptertest.Bundle(test.fixture, source), id, reference) + require.NoError(t, err) + assert.Equal(t, test.want, adaptertest.PrimaryText(t, projection)) + update := "Updated " + test.want + result, err := adapter.UpdateSource( + adaptertest.Bundle(test.fixture, source), id, reference, + adaptertest.WithText(projection, update), + ) + require.NoError(t, err) + updated := adaptertest.ApplyChecked(t, source, adaptertest.DirectEdits(t, result)) + reparsed, err := adapter.ParseSource(adaptertest.Bundle(test.fixture, updated), id, reference) + require.NoError(t, err) + assert.Equal(t, update, adaptertest.PrimaryText(t, reparsed)) + }) + } +} + +func TestFrameworkMessageListsAndBareCallIDs(t *testing.T) { + messageTests := []struct { + name, fixture, format, id string + }{ + {name: "OpenAI Python chat", fixture: "python/frameworks_tier1.py", format: "python", id: "openai_chat"}, + {name: "Anthropic Python messages", fixture: "python/frameworks_tier1.py", format: "python", id: "anthropic_message"}, + {name: "OpenAI TypeScript chat", fixture: "typescript/frameworks.ts", format: "typescript", id: "openaiChat"}, + {name: "Anthropic TypeScript messages", fixture: "typescript/frameworks.ts", format: "typescript", id: "anthropicMessage"}, + {name: "Vercel streamText messages", fixture: "typescript/frameworks.ts", format: "typescript", id: "streamed"}, + } + for _, test := range messageTests { + t.Run(test.name, func(t *testing.T) { + source := fixtureFile(t, test.fixture) + id := syncdomain.VariationID("project", "config", "default") + reference := syncdomain.Reference{ + File: test.fixture, Format: test.format, Select: `prompts[id="` + test.id + `"]`, + Map: map[string]string{"messages": "messages"}, + } + projection, err := adapter.ParseSource( + adaptertest.Bundle(test.fixture, source), id, reference, + ) + require.NoError(t, err) + messages := append([]syncdomain.Message{}, (*projection.(syncdomain.VariationProjection).Messages)...) + require.NotEmpty(t, messages) + messages[0].Content = "Updated framework message." + result, err := adapter.UpdateSource( + adaptertest.Bundle(test.fixture, source), id, reference, + syncdomain.VariationProjection{ + Mode: syncdomain.VariationModeCompletion, Messages: &messages, + }, + ) + require.NoError(t, err) + assert.Equal(t, adapter.CapabilityDirect, result.Fields[syncdomain.FieldMessages].Capability) + adaptertest.ApplyChecked(t, source, result.Fields[syncdomain.FieldMessages].Edits) + }) + } + + bareTests := []struct { + name, fixture, format, id, path, want string + }{ + {name: "OpenAI Python bare call", fixture: "python/frameworks_tier1.py", format: "python", id: "bare_calls.openai.responses.create", path: "instructions", want: "Bare response prompt."}, + {name: "Anthropic Python bare call", fixture: "python/frameworks_tier1.py", format: "python", id: "bare_calls.anthropic.messages.create", path: "system", want: "Bare Anthropic prompt."}, + {name: "OpenAI TypeScript bare call", fixture: "typescript/frameworks.ts", format: "typescript", id: "bareCalls.openai.responses.create", path: "instructions", want: "Bare OpenAI response."}, + {name: "Anthropic TypeScript bare call", fixture: "typescript/frameworks.ts", format: "typescript", id: "bareCalls.anthropic.messages.create", path: "system", want: "Bare Anthropic message."}, + } + for _, test := range bareTests { + t.Run(test.name, func(t *testing.T) { + source := fixtureFile(t, test.fixture) + id := syncdomain.VariationID("project", "config", "default") + reference := syncdomain.Reference{ + File: test.fixture, Format: test.format, Select: `prompts[id="` + test.id + `"]`, + Map: map[string]string{"prompt": test.path}, + } + projection, err := adapter.ParseSource(adaptertest.Bundle(test.fixture, source), id, reference) + require.NoError(t, err) + assert.Equal(t, test.want, *projection.(syncdomain.VariationProjection).Instructions) + }) + } +} + +func TestFrameworkSchemasAndFunctionBuiltPromptsStayReadOnly(t *testing.T) { + tests := []struct { + name, fixture, format, selectPath, field, path string + kind syncdomain.Kind + }{ + {name: "Pydantic type hints", fixture: "python/frameworks_tier2.py", format: "python", selectPath: `tools[id="pydantic_lookup"]`, field: "schema", path: "schema", kind: syncdomain.KindTool}, + {name: "FastMCP prompt function", fixture: "python/frameworks_tier1.py", format: "python", selectPath: `prompts[id="mcp_prompt"]`, field: "prompt", path: "template", kind: syncdomain.KindVariation}, + {name: "Pydantic system prompt function", fixture: "python/frameworks_tier2.py", format: "python", selectPath: `prompts[id="pydantic_system_prompt"]`, field: "prompt", path: "template", kind: syncdomain.KindVariation}, + {name: "LangChain Zod schema", fixture: "typescript/frameworks.ts", format: "typescript", selectPath: `tools[id="langchainLookup"]`, field: "schema", path: "schema", kind: syncdomain.KindTool}, + {name: "MCP Zod schema", fixture: "typescript/frameworks.ts", format: "typescript", selectPath: `tools[id="mcp_lookup"]`, field: "schema", path: "schema", kind: syncdomain.KindTool}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + source := fixtureFile(t, test.fixture) + id := syncdomain.VariationID("project", "config", "default") + if test.kind == syncdomain.KindTool { + id = syncdomain.ResourceID{Kind: syncdomain.KindTool, ProjectKey: "project", LookupKey: "tool"} + } + reference := syncdomain.Reference{ + File: test.fixture, Format: test.format, Select: test.selectPath, + Map: map[string]string{test.field: test.path}, + } + projection, err := adapter.ParseSource(adaptertest.Bundle(test.fixture, source), id, reference) + require.NoError(t, err) + state, ok := projection.States()[syncdomain.Field(test.field)] + require.True(t, ok) + assert.Equal(t, syncdomain.CapabilityReadOnly, state.Capability) + }) + } +} + +func TestImportedLiteralReadAndWrite(t *testing.T) { + tests := []struct { + name, format, primaryPath, dependencyPath string + }{ + {name: "Python relative import", format: "python", primaryPath: "app/agent.py", dependencyPath: "app/prompts.py"}, + {name: "TypeScript relative import", format: "typescript", primaryPath: "src/agent.ts", dependencyPath: "src/prompts.ts"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + fixtureRoot := "imports/python/" + if test.format == "typescript" { + fixtureRoot = "imports/typescript/" + } + primary := fixtureFile(t, fixtureRoot+test.primaryPath) + dependency := fixtureFile(t, fixtureRoot+test.dependencyPath) + bundle := adapter.SourceBundle{ + Primary: adapter.Document{Path: test.primaryPath, Data: primary}, + Dependencies: map[string]adapter.Document{ + test.dependencyPath: {Path: test.dependencyPath, Data: dependency}, + }, + } + paths, err := adapter.DependencyPaths(test.format, bundle.Primary) + require.NoError(t, err) + assert.Contains(t, paths, test.dependencyPath) + id := syncdomain.VariationID("project", "config", "default") + reference := syncdomain.Reference{ + File: test.primaryPath, Format: test.format, Select: `agents[id="support"]`, + Map: map[string]string{"prompt": "instructions"}, + } + projection, err := adapter.ParseSource(bundle, id, reference) + require.NoError(t, err) + assert.Equal(t, + "Ask for an order ID before discussing an order.", + *projection.(syncdomain.VariationProjection).Instructions, + ) + + prompt := "Ask for an order ID and region." + result, err := adapter.UpdateSource( + bundle, id, reference, + syncdomain.VariationProjection{Mode: syncdomain.VariationModeAgent, Instructions: &prompt}, + ) + require.NoError(t, err) + edits := adaptertest.DirectEdits(t, result) + require.Len(t, edits, 1) + assert.Equal(t, test.dependencyPath, edits[0].File) + updated := adaptertest.ApplyChecked(t, dependency, edits) + bundle.Dependencies[test.dependencyPath] = adapter.Document{Path: test.dependencyPath, Data: updated} + reparsed, err := adapter.ParseSource(bundle, id, reference) + require.NoError(t, err) + assert.Equal(t, prompt, *reparsed.(syncdomain.VariationProjection).Instructions) + assert.Equal(t, primary, bundle.Primary.Data) + }) + } +} + +func TestUnresolvedImportIsReadOnlyWithSourceHint(t *testing.T) { + source := []byte(`from agents import Agent +from third_party.prompts import SUPPORT_PROMPT +support = Agent(instructions=SUPPORT_PROMPT, model="gpt-5") +`) + id := syncdomain.VariationID("project", "config", "default") + reference := syncdomain.Reference{ + File: "agent.py", Format: "python", Select: `agents[id="support"]`, + Map: map[string]string{"prompt": "instructions"}, + } + + projection, err := adapter.ParseSource( + adapter.SourceBundle{Primary: adapter.Document{Path: "agent.py", Data: source}}, + id, reference, + ) + + require.NoError(t, err) + state, ok := projection.States()[syncdomain.FieldPrompt] + require.True(t, ok) + assert.Equal(t, syncdomain.CapabilityReadOnly, state.Capability) + assert.Equal(t, syncdomain.ErrorImportUnresolved, state.Code) + assert.NotEmpty(t, state.File) +} + +func TestDynamicExpressionsAreReadOnly(t *testing.T) { + source := []byte(`import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +const server = new McpServer({ name: "orders", version: "1" }); +server.registerTool("lookup", { + description: ` + "`Look up ${kind}.`" + `, +}, handler); +`) + id := syncdomain.ResourceID{Kind: syncdomain.KindTool, ProjectKey: "project", LookupKey: "lookup"} + reference := syncdomain.Reference{ + File: "tools.ts", Format: "typescript", Select: `tools[id="lookup"]`, + Map: map[string]string{"description": "description"}, + } + description := "Look up one order." + update, err := adapter.UpdateSource( + adaptertest.Bundle(reference.File, source), id, reference, + syncdomain.ToolProjection{Description: &description}, + ) + require.NoError(t, err) + assert.Equal(t, adapter.CapabilityReadOnly, update.Fields[syncdomain.FieldDescription].Capability) + assert.Empty(t, update.Fields[syncdomain.FieldDescription].Edits) +} + +func TestCodeAdaptersRejectInvalidUTF8(t *testing.T) { + id := syncdomain.VariationID("project", "support", "default") + reference := syncdomain.Reference{ + File: "agents.py", Format: "python", Select: `agents[id="support"]`, + Map: map[string]string{"prompt": "instructions"}, + } + + _, err := adapter.ParseSource(adaptertest.Bundle(reference.File, []byte{0xff}), id, reference) + + require.ErrorContains(t, err, "valid UTF-8") +} diff --git a/internal/sync/adapter/all/conformance_test.go b/internal/sync/adapter/all/conformance_test.go new file mode 100644 index 000000000..341921216 --- /dev/null +++ b/internal/sync/adapter/all/conformance_test.go @@ -0,0 +1,358 @@ +package all + +import ( + "io/fs" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" + "github.com/launchdarkly/ldcli/internal/sync/adapter" + "github.com/launchdarkly/ldcli/internal/sync/adapter/adaptertest" +) + +func TestRegisteredFormatsReadAndWriteRealisticSources(t *testing.T) { + tests := []struct { + name string + fixture string + realPath string + format string + id syncdomain.ResourceID + selectPath string + fieldMap map[string]string + want string + update string + }{ + { + name: "Markdown section", fixture: "markdown/agents-instructions.md.golden", format: "markdown", + id: syncdomain.VariationID("project", "support", "default"), + selectPath: `sections[heading="Support agent"]`, fieldMap: map[string]string{"prompt": "body"}, + want: "Ask for an order ID before discussing an order.", update: "Ask for an order ID and region.", + }, + { + name: "Agent Skill", fixture: "agent-skill/refunds/skill-instructions.md.golden", + realPath: ".agents/skills/refunds/SKILL.md", format: "agent-skill", + id: syncdomain.ResourceID{Kind: syncdomain.KindSkill, ProjectKey: "project", LookupKey: "refunds"}, + want: "Explain the refund policy and escalation rules.", update: "Explain refunds and escalation rules.", + }, + { + name: "Python agent", fixture: "python/agents.py", format: "python", + id: syncdomain.VariationID("project", "support", "default"), + selectPath: `agents[id="support"]`, fieldMap: map[string]string{"prompt": "instructions"}, + want: "Ask for an order ID before discussing an order.", update: "Ask for an order ID and region.", + }, + { + name: "TypeScript MCP tool", fixture: "typescript/server.ts", format: "typescript", + id: syncdomain.ResourceID{Kind: syncdomain.KindTool, ProjectKey: "project", LookupKey: "lookup_order"}, + selectPath: `tools[id="lookup_order"]`, fieldMap: map[string]string{"description": "description"}, + want: "Look up one order.", update: "Look up one order and status.", + }, + { + name: "JavaScript MCP tool", fixture: "javascript/server.js", format: "javascript", + id: syncdomain.ResourceID{Kind: syncdomain.KindTool, ProjectKey: "project", LookupKey: "weather"}, + selectPath: `tools[id="weather"]`, fieldMap: map[string]string{"description": "description"}, + want: "Return the weather for one city.", update: "Return weather and alerts.", + }, + { + name: "JSON tool", fixture: "json/mcp.json", format: "json", + id: syncdomain.ResourceID{Kind: syncdomain.KindTool, ProjectKey: "project", LookupKey: "lookup_order"}, + selectPath: `tools[name="lookup_order"]`, fieldMap: map[string]string{"description": "description"}, + want: "Look up one order.", update: "Look up one order and status.", + }, + { + name: "YAML agent", fixture: "yaml/agents.yaml", format: "yaml", + id: syncdomain.VariationID("project", "support", "default"), + selectPath: `agents[name="support"]`, fieldMap: map[string]string{"prompt": "prompt"}, + want: "Ask for an order ID.", update: "Ask for an order ID and region.", + }, + { + name: "TOML agent", fixture: "toml/pyproject.toml", format: "toml", + id: syncdomain.VariationID("project", "support", "default"), + selectPath: "tool.agent.support", fieldMap: map[string]string{"prompt": "prompt"}, + want: "Ask for an order ID.\nKeep the answer short.", update: "Ask for an order ID and region.", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + source := fixtureFile(t, test.fixture) + documentPath := test.fixture + if test.realPath != "" { + documentPath, source = materializeFixture(t, test.fixture, test.realPath) + } + reference := syncdomain.Reference{ + File: documentPath, Format: test.format, Select: test.selectPath, Map: test.fieldMap, + } + projection, err := adapter.ParseSource(adaptertest.Bundle(documentPath, source), test.id, reference) + require.NoError(t, err) + assert.Equal(t, test.want, adaptertest.PrimaryText(t, projection)) + + desired := adaptertest.WithText(projection, test.update) + result, err := adapter.UpdateSource(adaptertest.Bundle(documentPath, source), test.id, reference, desired) + require.NoError(t, err) + edits := adaptertest.DirectEdits(t, result) + updated := adaptertest.ApplyChecked(t, source, edits) + reparsed, err := adapter.ParseSource(adaptertest.Bundle(documentPath, updated), test.id, reference) + require.NoError(t, err) + assert.Equal(t, test.update, adaptertest.PrimaryText(t, reparsed)) + }) + } +} + +func TestProjectionValidationCoversEveryResourceKind(t *testing.T) { + _, err := adapter.NormalizeReference( + syncdomain.ResourceID{Kind: syncdomain.KindTool, ProjectKey: "project", LookupKey: "tool"}, + syncdomain.Reference{File: "tool.json", Format: "json", Map: map[string]string{"prompt": "value"}}, + ) + problem, ok := syncdomain.ProblemFrom(err) + require.True(t, ok) + assert.Equal(t, syncdomain.ErrorInvalidReference, problem.Code) + + _, err = adapter.NormalizeReference( + syncdomain.ResourceID{Kind: syncdomain.KindSkill, ProjectKey: "project", LookupKey: "skill"}, + syncdomain.Reference{File: "skill.toml", Format: "toml", Map: map[string]string{"schema": "value"}}, + ) + problem, ok = syncdomain.ProblemFrom(err) + require.True(t, ok) + assert.Equal(t, syncdomain.ErrorInvalidReference, problem.Code) +} + +func TestUpdateSourceRejectsWrongProjectionAndMissingMap(t *testing.T) { + id := syncdomain.VariationID("project", "config", "default") + reference := syncdomain.Reference{ + File: "prompt.json", Format: "json", Map: map[string]string{"prompt": "missing"}, + } + _, err := adapter.UpdateSource( + adaptertest.Bundle(reference.File, []byte(`{"prompt":"Help"}`)), + id, reference, + syncdomain.ToolProjection{}, + ) + require.ErrorContains(t, err, "projection kind") + + _, err = adapter.UpdateSource( + adaptertest.Bundle(reference.File, []byte(`{"prompt":"Help"}`)), + id, reference, + syncdomain.VariationProjection{Mode: syncdomain.VariationModeAgent, Instructions: adaptertest.StringPointer("Next")}, + ) + problem, ok := syncdomain.ProblemFrom(err) + require.True(t, ok) + assert.Equal(t, syncdomain.ErrorPathNotFound, problem.Code) +} + +func TestSourceEntryPointsPreserveStructuredFailures(t *testing.T) { + id := syncdomain.VariationID("project", "config", "default") + _, err := adapter.NormalizeReference(id, syncdomain.Reference{ + File: "value.txt", Format: "missing", Select: "$", + Map: map[string]string{string(syncdomain.FieldPrompt): "$"}, + }) + require.Error(t, err) + _, err = adapter.NormalizeReference(id, syncdomain.Reference{ + File: "value.json", Format: "json", Select: "$", Map: map[string]string{}, + }) + require.Error(t, err) + _, err = adapter.NormalizeReference(id, syncdomain.Reference{ + File: "value.json", Format: "json", Select: "$", + Map: map[string]string{string(syncdomain.FieldPrompt): "invalid["}, + }) + require.Error(t, err) + _, err = adapter.NormalizeReference(id, syncdomain.Reference{ + File: "main.py", Format: "python", + Map: map[string]string{string(syncdomain.FieldPrompt): "template"}, + }) + require.Error(t, err) + _, err = adapter.ParseSource(adapter.SourceBundle{}, id, syncdomain.Reference{ + File: "value.json", Format: "missing", Select: "$", + Map: map[string]string{string(syncdomain.FieldPrompt): "$"}, + }) + require.Error(t, err) + _, err = adapter.ParseSource(adaptertest.Bundle("value.json", []byte(`{"prompt":"Help"}`)), id, syncdomain.Reference{ + File: "value.json", Format: "json", Select: "missing", + Map: map[string]string{string(syncdomain.FieldPrompt): "prompt"}, + }) + require.Error(t, err) + _, err = adapter.ParseSource(adaptertest.Bundle("value.json", []byte(`{"prompt":`)), id, syncdomain.Reference{ + File: "value.json", Format: "json", Select: "$", + Map: map[string]string{string(syncdomain.FieldPrompt): "prompt"}, + }) + require.Error(t, err) + _, err = adapter.UpdateSource( + adapter.SourceBundle{}, id, syncdomain.Reference{ + File: "value.json", Format: "missing", Select: "$", + Map: map[string]string{string(syncdomain.FieldPrompt): "$"}, + }, + syncdomain.VariationProjection{}, + ) + require.Error(t, err) + _, err = adapter.UpdateSource( + adaptertest.Bundle("value.json", []byte(`{"prompt":`)), id, syncdomain.Reference{ + File: "value.json", Format: "json", Select: "$", + Map: map[string]string{string(syncdomain.FieldPrompt): "prompt"}, + }, + syncdomain.VariationProjection{}, + ) + require.Error(t, err) + _, err = adapter.UpdateSource( + adaptertest.Bundle("value.json", []byte(`{"prompt":"Help"}`)), id, syncdomain.Reference{ + File: "value.json", Format: "json", Select: "missing", + Map: map[string]string{string(syncdomain.FieldPrompt): "prompt"}, + }, + syncdomain.VariationProjection{}, + ) + require.Error(t, err) + + messages := []syncdomain.Message{ + {Role: syncdomain.RoleSystem, Content: "one"}, + {Role: syncdomain.RoleUser, Content: "two"}, + } + result, err := adapter.UpdateSource( + adaptertest.Bundle("value.json", []byte(`{"prompt":"Help"}`)), + id, syncdomain.Reference{ + File: "value.json", Format: "json", Select: "$", + Map: map[string]string{string(syncdomain.FieldPrompt): "prompt"}, + }, + syncdomain.VariationProjection{Messages: &messages}, + ) + require.NoError(t, err) + assert.Equal(t, adapter.CapabilityReadOnly, result.Fields[syncdomain.FieldPrompt].Capability) + +} + +func TestNormalizeReferenceRejectsOverlappingOrInvalidParameterTargets(t *testing.T) { + id := syncdomain.VariationID("project", "config", "default") + tests := []struct { + name string + targets map[string]string + code syncdomain.ErrorCode + message string + }{ + { + name: "whole and child overlap", + targets: map[string]string{ + "prompt": "prompt", "model.parameters": "parameters", + `model.parameters["temperature"]`: "temperature", + }, + code: syncdomain.ErrorInvalidReference, message: "overlaps", + }, + { + name: "same exact key through JSON escape", + targets: map[string]string{ + "prompt": "prompt", `model.parameters["top_k"]`: "top_k", + `model.parameters["top\u005fk"]`: "other", + }, + code: syncdomain.ErrorInvalidReference, message: "same model parameter", + }, + { + name: "expanded tools child", + targets: map[string]string{ + "prompt": "prompt", `model.parameters["tools"]`: "tools", + }, + code: syncdomain.ErrorInvalidReference, message: "duplicates the tools field", + }, + { + name: "child needs exact bracket key", + targets: map[string]string{ + "prompt": "prompt", "model.parameters.temperature": "temperature", + }, + code: syncdomain.ErrorInvalidReference, message: "unsupported mapped field", + }, + { + name: "target uses shared path syntax", + targets: map[string]string{ + "prompt": "prompt", `model.parameters["temperature"`: "temperature", + }, + code: syncdomain.ErrorInvalidPathSyntax, message: "invalid path", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := adapter.NormalizeReference(id, syncdomain.Reference{ + File: "agent.json", Format: "json", Map: test.targets, + }) + require.Error(t, err) + problem, ok := syncdomain.ProblemFrom(err) + require.True(t, ok) + assert.Equal(t, test.code, problem.Code) + assert.ErrorContains(t, err, test.message) + }) + } +} + +func TestExternalAdapterNeedsOnlyRegistration(t *testing.T) { + require.NoError(t, adapter.Register(externalFakeAdapter{})) + id := syncdomain.VariationID("project", "config", "default") + + projection, err := adapter.ParseSource( + adapter.SourceBundle{ + Primary: adapter.Document{Path: "prompt.fake", Data: []byte("External adapter")}, + }, + id, syncdomain.Reference{File: "prompt.fake", Format: "external-fake"}, + ) + + require.NoError(t, err) + require.Equal(t, "External adapter", *projection.(syncdomain.VariationProjection).Instructions) +} + +func TestAdapterFixturesHaveNoToolLoadedInstructionPaths(t *testing.T) { + root := filepath.Join(repositoryRoot(t), "internal", "sync") + err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { + require.NoError(t, err) + relative, err := filepath.Rel(root, path) + require.NoError(t, err) + relative = filepath.ToSlash(relative) + if !insideTestdata(relative) { + return nil + } + require.Falsef(t, forbiddenInstructionPath(relative), "forbidden adapter fixture path: %s", relative) + return nil + }) + require.NoError(t, err) +} + +func FuzzPathIndexNeverPanics(f *testing.F) { + for _, path := range []string{ + "items[0]", "items[-1]", "items[1.5]", "items[]", "items[2147483648]", + "items[0][1]", `items[id="support"].messages[0]`, + } { + f.Add(path) + } + f.Fuzz(func(t *testing.T, path string) { + err := adapter.ParsePath(path) + if err == nil { + return + } + problem, ok := syncdomain.ProblemFrom(err) + require.True(t, ok) + require.Equal(t, syncdomain.ErrorInvalidPathSyntax, problem.Code) + }) +} + +func TestReferenceValidationUsesStableProblem(t *testing.T) { + id := syncdomain.VariationID("project", "config", "default") + tests := []syncdomain.Reference{ + {Format: "python", Select: `agents[id="support"]`, Map: map[string]string{"prompt": "instructions"}}, + {File: "agents.py", Format: "python", Map: map[string]string{"prompt": "instructions"}}, + {File: "agents.py", Format: "python", Select: `agents[id="support"]`}, + {File: "agents.py", Format: "python", Select: `agents[id="support"]`, Map: map[string]string{"description": "instructions"}}, + } + for _, reference := range tests { + _, err := adapter.NormalizeReference(id, reference) + problem, ok := syncdomain.ProblemFrom(err) + require.True(t, ok) + assert.Equal(t, syncdomain.ErrorInvalidReference, problem.Code) + assert.Equal(t, id.Label(), problem.Resource) + } +} + +func FuzzPathParserNeverPanics(f *testing.F) { + for _, value := range []string{ + "$", `agents[id="support"]`, `items[role="system",name="intro"]`, + `metadata["display.name"]`, "items[", + } { + f.Add(value) + } + f.Fuzz(func(t *testing.T, value string) { + _ = adapter.ParsePath(value) + }) +} diff --git a/internal/sync/adapter/all/ecmascript_test.go b/internal/sync/adapter/all/ecmascript_test.go new file mode 100644 index 000000000..f150a7197 --- /dev/null +++ b/internal/sync/adapter/all/ecmascript_test.go @@ -0,0 +1,244 @@ +package all + +import ( + "strings" + "testing" + "unicode/utf8" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" + "github.com/launchdarkly/ldcli/internal/sync/adapter" + "github.com/launchdarkly/ldcli/internal/sync/adapter/adaptertest" + "github.com/launchdarkly/ldcli/internal/sync/adapter/code" +) + +func TestTypeScriptLangChainAndQualifiedConstant(t *testing.T) { + source := fixtureFile(t, "typescript/langchain.ts") + id := syncdomain.VariationID("project", "config", "default") + tests := []struct { + name, selectPath, field, mapPath string + mode syncdomain.VariationMode + }{ + {name: "messages", selectPath: `prompts[id="chat"]`, field: "messages", mapPath: "messages", mode: syncdomain.VariationModeCompletion}, + {name: "constant messages", selectPath: `prompts[id="sharedChat"]`, field: "messages", mapPath: "messages", mode: syncdomain.VariationModeCompletion}, + {name: "template", selectPath: `prompts[id="simple"]`, field: "prompt", mapPath: "template", mode: syncdomain.VariationModeAgent}, + {name: "constructor", selectPath: `prompts[id="constructor"]`, field: "messages", mapPath: "messages", mode: syncdomain.VariationModeCompletion}, + {name: "function constant", selectPath: `constants[id="build.prompt"]`, field: "prompt", mapPath: "$", mode: syncdomain.VariationModeAgent}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + reference := syncdomain.Reference{ + File: "langchain.ts", Format: "typescript", Select: test.selectPath, + Map: map[string]string{test.field: test.mapPath}, + } + projection, err := adapter.ParseSource(adaptertest.Bundle(reference.File, source), id, reference) + require.NoError(t, err) + assert.NotNil(t, projection) + }) + } +} + +func TestTypeScriptAndJavaScriptConstantLiteralKinds(t *testing.T) { + source := []byte(`const CONFIG = { + single: 'one', + double: "two", + template: ` + "`three`" + `, + retries: 3, + enabled: true, + models: ["gpt-5", "claude-sonnet"], + nested: { prompt: "Help" }, +};`) + for _, format := range []string{"typescript", "javascript"} { + t.Run(format, func(t *testing.T) { + root, err := adapter.ParseDocument(adapter.Document{Path: "config.js", Data: source}, format) + require.NoError(t, err) + tests := []struct { + path string + want any + }{ + {path: `constants[id="CONFIG"].single`, want: "one"}, + {path: `constants[id="CONFIG"].double`, want: "two"}, + {path: `constants[id="CONFIG"].template`, want: "three"}, + {path: `constants[id="CONFIG"].retries`, want: 3.0}, + {path: `constants[id="CONFIG"].enabled`, want: true}, + {path: `constants[id="CONFIG"].models`, want: []any{"gpt-5", "claude-sonnet"}}, + {path: `constants[id="CONFIG"].nested.prompt`, want: "Help"}, + } + for _, test := range tests { + node, err := root.Resolve(test.path) + require.NoError(t, err) + assert.Equal(t, test.want, node.Value) + } + }) + } +} + +// The Vercel AI SDK sends a prompt as it is, so sync keeps its text literal: +// a `${name}` in a plain string is text, and LaunchDarkly placeholders are +// written back without a change. +func TestAISDKPromptTextRoundTripsWithoutTranslation(t *testing.T) { + source := []byte(`import { generateText } from "ai"; +const result = generateText({ prompt: "Hello ${customer}." }); +`) + id := syncdomain.VariationID("project", "config", "default") + reference := syncdomain.Reference{ + File: "prompt.ts", Format: "typescript", Select: `prompts[id="result"]`, + Map: map[string]string{"prompt": "prompt"}, + } + projection, err := adapter.ParseSource( + adaptertest.Bundle(reference.File, source), id, reference, + ) + require.NoError(t, err) + assert.Equal(t, "Hello ${customer}.", *projection.(syncdomain.VariationProjection).Instructions) + + next := "Welcome {{customer_name}}." + result, err := adapter.UpdateSource( + adaptertest.Bundle(reference.File, source), id, reference, + syncdomain.VariationProjection{Mode: syncdomain.VariationModeAgent, Instructions: &next}, + ) + require.NoError(t, err) + updated := adaptertest.ApplyChecked(t, source, adaptertest.DirectEdits(t, result)) + assert.Contains(t, string(updated), `"Welcome {{customer_name}}."`) +} + +func TestTypeScriptToolUsesImportAwareInspector(t *testing.T) { + source := []byte(`import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +const server = new McpServer({ name: "orders", version: "1" }); +server.registerTool("lookup", { + description: "Look up an order.", + inputSchema: { type: "object" }, +}, handler); +`) + id := syncdomain.ResourceID{Kind: syncdomain.KindTool, ProjectKey: "project", LookupKey: "lookup"} + reference := syncdomain.Reference{ + File: "tools.ts", Format: "typescript", Select: `tools[id="lookup"]`, + Map: map[string]string{"description": "description", "schema": "inputSchema"}, + } + projection, err := adapter.ParseSource(adaptertest.Bundle(reference.File, source), id, reference) + require.NoError(t, err) + tool := projection.(syncdomain.ToolProjection) + assert.Equal(t, "Look up an order.", *tool.Description) + assert.Equal(t, "object", (*tool.Schema)["type"]) + + description := "Look up one order and its status." + schema := map[string]any{"type": "object", "required": []any{"orderId"}} + update, err := adapter.UpdateSource( + adaptertest.Bundle(reference.File, source), id, reference, + syncdomain.ToolProjection{Description: &description, Schema: &schema}, + ) + require.NoError(t, err) + assert.Equal(t, adapter.CapabilityDirect, update.Fields[syncdomain.FieldDescription].Capability) + assert.Equal(t, adapter.CapabilityDirect, update.Fields[syncdomain.FieldSchema].Capability) +} + +func TestTypeScriptAgentProjection(t *testing.T) { + source := []byte(`import { Agent } from "@openai/agents"; +const support = new Agent({ + instructions: "Help with orders.", + model: "gpt-5", +}); +`) + id := syncdomain.VariationID("project", "support", "default") + reference := syncdomain.Reference{ + File: "agents.ts", Format: "typescript", Select: `agents[id="support"]`, + Map: map[string]string{"prompt": "instructions", "model": "model"}, + } + + projection, err := adapter.ParseSource(adaptertest.Bundle(reference.File, source), id, reference) + + require.NoError(t, err) + variation := projection.(syncdomain.VariationProjection) + assert.Equal(t, "Help with orders.", *variation.Instructions) + assert.Equal(t, "gpt-5", *variation.Model) +} + +func TestTypeScriptPromptProjection(t *testing.T) { + source := []byte(`import { PromptTemplate } from "@langchain/core/prompts"; +const summary = new PromptTemplate({ + template: "Summarize the case.", +}); +`) + id := syncdomain.VariationID("project", "summary", "default") + reference := syncdomain.Reference{ + File: "prompts.ts", Format: "typescript", Select: `prompts[id="summary"]`, + Map: map[string]string{"prompt": "template"}, + } + + projection, err := adapter.ParseSource(adaptertest.Bundle(reference.File, source), id, reference) + + require.NoError(t, err) + assert.Equal(t, "Summarize the case.", *projection.(syncdomain.VariationProjection).Instructions) +} + +func FuzzTypeScriptStringRoundTrip(f *testing.F) { + for _, value := range []string{"plain", "quotes ' \" and slash \\", "line one\nline two", "Unicode 🌍"} { + f.Add(value) + } + f.Fuzz(func(t *testing.T, value string) { + if !utf8.ValidString(value) { + t.Skip() + } + for _, quote := range []byte{'\'', '"', '`'} { + if quote == '`' && strings.Contains(value, "${") { + continue + } + encoded, err := code.EncodeJSString(value, quote) + require.NoError(t, err) + decoded, _, err := code.DecodeJSString(encoded) + require.NoError(t, err) + require.Equal(t, value, decoded) + } + }) +} + +// An agent without a name has no identity of its own, so its id is its +// position. A link to such an id is as fragile as an index, and the indexed +// selection makes a sync notice when another agent takes the position. +func TestFallbackIDsAreOrdinalAndRecordAnIndexedSelection(t *testing.T) { + id := syncdomain.VariationID("project", "config", "default") + reference := syncdomain.Reference{ + File: "team.ts", Format: "typescript", Select: `agents[id="Agent#2"]`, + Map: map[string]string{"prompt": "instructions"}, + } + selection := func(source string) (string, syncdomain.IndexedSelection) { + projection, selections, err := adapter.ParseSourceWithSelections( + adaptertest.Bundle("team.ts", []byte("import { Agent } from \"@openai/agents\";\n"+source)), id, reference, + ) + require.NoError(t, err) + require.Len(t, selections, 1, "a fallback id records one indexed selection") + return *projection.(syncdomain.VariationProjection).Instructions, selections[0] + } + + before, beforeSelection := selection(`const team = [new Agent({ instructions: "A" }), new Agent({ instructions: "B" })];`) + after, afterSelection := selection(`const team = [new Agent({ instructions: "NEW" }), new Agent({ instructions: "A" }), new Agent({ instructions: "B" })];`) + + assert.Equal(t, "B", before) + assert.Equal(t, "A", after, "an agent added above shifts the ordinal id to another agent") + assert.NotEqual(t, beforeSelection.Fingerprint, afterSelection.Fingerprint, "the indexed selection changes, so the plan blocks the sync") + assert.Equal(t, 2, beforeSelection.CollectionLength) + assert.Equal(t, 3, afterSelection.CollectionLength) + + _, edited := selection(`const team = [new Agent({ instructions: "A" }), new Agent({ instructions: "B2" })];`) + assert.Equal(t, beforeSelection.Fingerprint, edited.Fingerprint, "an edit to the selected agent is not a changed selection") + + // The first unnamed agent has no suffix, and it is positional as well. + reference.Select = `agents[id="Agent"]` + first, firstSelection := selection(`const team = [new Agent({ instructions: "A" }), new Agent({ instructions: "B" })];`) + shifted, shiftedSelection := selection(`const team = [new Agent({ instructions: "NEW" }), new Agent({ instructions: "A" }), new Agent({ instructions: "B" })];`) + assert.Equal(t, "A", first) + assert.Equal(t, "NEW", shifted) + assert.NotEqual(t, firstSelection.Fingerprint, shiftedSelection.Fingerprint, "the unsuffixed id is positional too") + + named := adaptertest.Bundle("team.ts", []byte(`import { Agent } from "@openai/agents"; +const team = [new Agent({ name: "triage", instructions: "NEW" }), new Agent({ name: "support", instructions: "A" })]; +`)) + namedReference := syncdomain.Reference{ + File: "team.ts", Format: "typescript", Select: `agents[id="support"]`, Map: map[string]string{"prompt": "instructions"}, + } + projection, selections, err := adapter.ParseSourceWithSelections(named, id, namedReference) + require.NoError(t, err) + assert.Equal(t, "A", *projection.(syncdomain.VariationProjection).Instructions, "a named agent keeps its id wherever it moves") + assert.Empty(t, selections, "a name is a stable identity") +} diff --git a/internal/sync/adapter/all/helpers_test.go b/internal/sync/adapter/all/helpers_test.go new file mode 100644 index 000000000..288359411 --- /dev/null +++ b/internal/sync/adapter/all/helpers_test.go @@ -0,0 +1,107 @@ +package all + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" + "github.com/launchdarkly/ldcli/internal/sync/adapter" + "github.com/launchdarkly/ldcli/internal/sync/adapter/adaptertest" +) + +type frameworkCase struct { + name, fixture, format, collection, id, sourcePath, want string + kind syncdomain.Kind +} + +type externalFakeAdapter struct{} + +func (externalFakeAdapter) Format() string { return "external-fake" } + +func (externalFakeAdapter) Behavior() string { return "external-fake/1" } + +func (externalFakeAdapter) Inspect(bundle adapter.SourceBundle) (*adapter.Node, error) { + document := bundle.Primary + return &adapter.Node{ + Value: string(document.Data), Start: 0, End: len(document.Data), + Capability: syncdomain.CapabilityDirect, + Encode: func(value any) ([]byte, error) { return []byte(value.(string)), nil }, + }, nil +} + +func (externalFakeAdapter) Defaults(syncdomain.Kind) (string, map[string]string) { + return "$", map[string]string{"prompt": "$"} +} + +// fixturePackages maps the first path segment of a fixture name to the +// package that owns the fixture. +var fixturePackages = map[string]string{ + "python": "python", "typescript": "ecmascript", "javascript": "ecmascript", + "json": "structured", "yaml": "structured", "toml": "structured", + "markdown": "markdown", "profiles": "markdown/profiles", "agent-skill": "markdown/agent-skill", +} + +// fixtureFile reads a fixture of one adapter package by its old shared name, +// such as python/agents.py. +func fixtureFile(t *testing.T, name string) []byte { + t.Helper() + first, rest, _ := strings.Cut(name, "/") + if first == "imports" { + language, file, _ := strings.Cut(rest, "/") + first, rest = language, "imports/"+file + } + directory, ok := fixturePackages[first] + require.True(t, ok, "unknown fixture %q", name) + parts := strings.SplitN(directory, "/", 2) + path := filepath.Join("..", parts[0], "testdata") + if len(parts) == 2 { + path = filepath.Join(path, parts[1]) + } + data, err := os.ReadFile(filepath.Join(path, filepath.FromSlash(rest))) + require.NoError(t, err) + return data +} + +func materializeFixture(t *testing.T, fixture, realPath string) (string, []byte) { + t.Helper() + data := fixtureFile(t, fixture) + return adaptertest.Materialize(t, data, realPath), data +} + +func repositoryRoot(t *testing.T) string { + t.Helper() + directory, err := os.Getwd() + require.NoError(t, err) + for { + if _, err := os.Stat(filepath.Join(directory, "go.mod")); err == nil { + return directory + } + parent := filepath.Dir(directory) + require.NotEqual(t, directory, parent, "repository root was not found") + directory = parent + } +} + +func forbiddenInstructionPath(path string) bool { + parts := strings.Split(path, "/") + for _, part := range parts { + switch part { + case ".cursor", ".claude", ".agents", ".cursorrules", "AGENTS.md", "CLAUDE.md", "SKILL.md": + return true + } + } + return path == ".github/copilot-instructions.md" || strings.HasPrefix(path, ".github/instructions/") +} + +func insideTestdata(path string) bool { + for _, part := range strings.Split(path, "/") { + if part == "testdata" { + return true + } + } + return false +} diff --git a/internal/sync/adapter/all/markdown_test.go b/internal/sync/adapter/all/markdown_test.go new file mode 100644 index 000000000..c8f0b8c55 --- /dev/null +++ b/internal/sync/adapter/all/markdown_test.go @@ -0,0 +1,206 @@ +package all + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" + "github.com/launchdarkly/ldcli/internal/sync/adapter" + "github.com/launchdarkly/ldcli/internal/sync/adapter/adaptertest" +) + +func TestMarkdownFrontMatterSurvivesBodyUpdates(t *testing.T) { + tests := []struct { + name, fixture, realPath, format, metadataPath string + metadataValue any + }{ + {name: "Cursor", fixture: "profiles/cursor-rule.mdc", realPath: ".cursor/rules/support.mdc", format: "markdown", metadataPath: "frontmatter.globs", metadataValue: "src/support/**/*.ts"}, + {name: "Copilot", fixture: "profiles/copilot.instructions.md", realPath: ".github/instructions/support.instructions.md", format: "markdown", metadataPath: "frontmatter.applyTo", metadataValue: "src/support/**/*.ts"}, + {name: "Claude", fixture: "profiles/claude-instructions.md.golden", realPath: "CLAUDE.md", format: "markdown", metadataPath: "frontmatter.model", metadataValue: "sonnet"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + documentPath, source := materializeFixture(t, test.fixture, test.realPath) + id := syncdomain.VariationID("project", "config", "default") + reference := syncdomain.Reference{File: documentPath, Format: test.format} + root, err := adapter.ParseDocument(adapter.Document{Path: documentPath, Data: source}, test.format) + require.NoError(t, err) + metadata, err := root.Resolve(test.metadataPath) + require.NoError(t, err) + assert.Equal(t, test.metadataValue, metadata.Value) + + projection, err := adapter.ParseSource(adaptertest.Bundle(documentPath, source), id, reference) + require.NoError(t, err) + prompt := "Updated profile instructions." + result, err := adapter.UpdateSource( + adaptertest.Bundle(documentPath, source), id, reference, + syncdomain.VariationProjection{Mode: syncdomain.VariationModeAgent, Instructions: &prompt}, + ) + require.NoError(t, err) + updated := adaptertest.ApplyChecked(t, source, adaptertest.DirectEdits(t, result)) + root, err = adapter.ParseDocument(adapter.Document{Path: documentPath, Data: updated}, test.format) + require.NoError(t, err) + metadata, err = root.Resolve(test.metadataPath) + require.NoError(t, err) + assert.Equal(t, test.metadataValue, metadata.Value) + assert.NotNil(t, projection) + }) + } +} + +func TestMarkdownSectionsAndMarkers(t *testing.T) { + documentPath, source := materializeFixture(t, "profiles/agents-instructions.md.golden", "AGENTS.md") + id := syncdomain.VariationID("project", "config", "default") + tests := []struct { + name, selectPath, mapPath, want string + }{ + {name: "section", selectPath: `sections[heading="Support"]`, mapPath: "body", want: "Ask for an order ID.\n\n\nExplain the refund policy.\n"}, + {name: "marker", selectPath: `markers[id="refunds"]`, mapPath: "body", want: "Explain the refund policy."}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + reference := syncdomain.Reference{ + File: documentPath, Format: "markdown", Select: test.selectPath, + Map: map[string]string{"prompt": test.mapPath}, + } + projection, err := adapter.ParseSource( + adaptertest.Bundle(reference.File, source), id, reference, + ) + require.NoError(t, err) + assert.Equal(t, test.want, *projection.(syncdomain.VariationProjection).Instructions) + prompt := "Updated selected instructions." + result, err := adapter.UpdateSource( + adaptertest.Bundle(reference.File, source), id, reference, + syncdomain.VariationProjection{Mode: syncdomain.VariationModeAgent, Instructions: &prompt}, + ) + require.NoError(t, err) + updated := adaptertest.ApplyChecked(t, source, adaptertest.DirectEdits(t, result)) + assert.Contains(t, string(updated), "## Build\n\nRun `make test`.") + }) + } +} + +func TestMarkdownSectionUpdatePreservesOtherBytes(t *testing.T) { + source := []byte("# Guide\r\n\r\n## Support\r\n\r\nHelp with orders.\r\n\r\n## Build\r\n\r\nRun make.\r\n") + id := syncdomain.VariationID("project", "support", "default") + reference := syncdomain.Reference{ + File: "AGENTS.md", Format: "markdown", Select: `sections[heading="Support"]`, + Map: map[string]string{"prompt": "body"}, + } + projection, err := adapter.ParseSource(adaptertest.Bundle("AGENTS.md", source), id, reference) + require.NoError(t, err) + assert.Equal(t, "Help with orders.", *projection.(syncdomain.VariationProjection).Instructions) + + next := "Help with orders and refunds." + update, err := adapter.UpdateSource( + adaptertest.Bundle("AGENTS.md", source), id, reference, + syncdomain.VariationProjection{Mode: syncdomain.VariationModeAgent, Instructions: &next}, + ) + require.NoError(t, err) + edit := update.Fields[syncdomain.FieldPrompt].Edits[0] + updated := adaptertest.ApplyEdits(source, edit) + assert.Equal(t, "# Guide\r\n\r\n## Support\r\n\r\nHelp with orders and refunds.\r\n\r\n## Build\r\n\r\nRun make.\r\n", string(updated)) +} + +func TestMarkdownSectionsIgnoreCodeAndKeepHeadingHashes(t *testing.T) { + source := []byte(" ## Not a heading\n\n````\n## Fenced\n````\n\n## C#\n\nLanguage guidance.\n") + id := syncdomain.VariationID("project", "guide", "default") + reference := syncdomain.Reference{ + File: "AGENTS.md", Format: "markdown", Select: `sections[heading="C#"]`, + Map: map[string]string{"prompt": "body"}, + } + + projection, err := adapter.ParseSource(adaptertest.Bundle(reference.File, source), id, reference) + + require.NoError(t, err) + assert.Equal(t, "Language guidance.", *projection.(syncdomain.VariationProjection).Instructions) +} + +func TestMarkdownRepresentsOneCompletionSystemMessage(t *testing.T) { + source := []byte("Current system prompt.\n") + id := syncdomain.VariationID("project", "completion", "default") + reference := syncdomain.Reference{File: "prompt.md", Format: "markdown"} + + projection, err := adapter.ParseSource(adaptertest.Bundle(reference.File, source), id, reference) + require.NoError(t, err) + completion, err := projection.(syncdomain.VariationProjection).ForMode(syncdomain.VariationModeCompletion) + require.NoError(t, err) + require.Equal(t, []syncdomain.Message{{Role: syncdomain.RoleSystem, Content: "Current system prompt."}}, *completion.Messages) + + desired := []syncdomain.Message{{Role: syncdomain.RoleSystem, Content: "Updated system prompt."}} + update, err := adapter.UpdateSource( + adaptertest.Bundle(reference.File, source), id, reference, + syncdomain.VariationProjection{Mode: syncdomain.VariationModeCompletion, Messages: &desired}, + ) + require.NoError(t, err) + assert.Equal(t, adapter.CapabilityDirect, update.Fields[syncdomain.FieldPrompt].Capability) +} + +func TestAgentSkillMapsDescriptionAndBody(t *testing.T) { + source := []byte("---\nname: refunds\ndescription: Explain refunds.\nlicense: Apache-2.0\n---\n\n# Refunds\n\nAsk for the order ID.\n") + id := syncdomain.ResourceID{Kind: syncdomain.KindSkill, ProjectKey: "project", LookupKey: "refunds"} + reference := syncdomain.Reference{File: ".agents/skills/refunds/SKILL.md", Format: "agent-skill"} + + projection, err := adapter.ParseSource(adaptertest.Bundle(reference.File, source), id, reference) + require.NoError(t, err) + skill := projection.(syncdomain.SkillProjection) + assert.Equal(t, "Explain refunds.", *skill.Description) + assert.Equal(t, "# Refunds\n\nAsk for the order ID.\n", *skill.Markdown) + + description := "Explain refunds and escalation." + markdown := "# Refunds\n\nEscalate large refunds.\n" + update, err := adapter.UpdateSource( + adaptertest.Bundle(reference.File, source), id, reference, + syncdomain.SkillProjection{Description: &description, Markdown: &markdown}, + ) + require.NoError(t, err) + assert.Equal(t, adapter.CapabilityDirect, update.Fields[syncdomain.FieldDescription].Capability) + assert.Equal(t, adapter.CapabilityDirect, update.Fields[syncdomain.FieldMarkdown].Capability) +} + +func TestAgentSkillDescriptionUpdatePreservesInlineComment(t *testing.T) { + source := []byte("---\nname: refunds\ndescription: \"Explain refunds.\" # catalog text\n---\n\n# Refunds\n") + id := syncdomain.ResourceID{Kind: syncdomain.KindSkill, ProjectKey: "project", LookupKey: "refunds"} + reference := syncdomain.Reference{File: "SKILL.md", Format: "agent-skill"} + description := "Explain refunds and escalation." + + update, err := adapter.UpdateSource( + adaptertest.Bundle(reference.File, source), id, reference, + syncdomain.SkillProjection{Description: &description}, + ) + + require.NoError(t, err) + updated := adaptertest.ApplyEdits(source, update.Fields[syncdomain.FieldDescription].Edits...) + assert.Contains(t, string(updated), `description: "Explain refunds and escalation." # catalog text`) +} + +func TestAgentSkillRejectsInvalidFiles(t *testing.T) { + id := syncdomain.ResourceID{Kind: syncdomain.KindSkill, ProjectKey: "project", LookupKey: "refunds"} + reference := syncdomain.Reference{File: "SKILL.md", Format: "agent-skill"} + tests := []struct { + name, source, message string + }{ + {name: "missing front matter", source: "# Skill\n", message: "missing YAML front matter"}, + {name: "unclosed front matter", source: "---\ndescription: Help\n", message: "unclosed YAML front matter"}, + {name: "missing description", source: "---\nname: refunds\n---\n\n# Skill\n", message: "path_not_found"}, + {name: "missing body", source: "---\ndescription: Help\n---\n", message: "skill Markdown is required"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := adapter.ParseSource( + adaptertest.Bundle(reference.File, []byte(test.source)), id, reference, + ) + require.Error(t, err) + problem, ok := syncdomain.ProblemFrom(err) + require.True(t, ok) + if test.message == "path_not_found" { + assert.Equal(t, syncdomain.ErrorPathNotFound, problem.Code) + } else { + assert.Equal(t, syncdomain.ErrorInvalidSource, problem.Code) + assert.Contains(t, err.Error(), test.message) + } + }) + } +} diff --git a/internal/sync/adapter/all/python_test.go b/internal/sync/adapter/all/python_test.go new file mode 100644 index 000000000..0674b1dc3 --- /dev/null +++ b/internal/sync/adapter/all/python_test.go @@ -0,0 +1,518 @@ +package all + +import ( + "strconv" + "testing" + "unicode/utf8" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" + "github.com/launchdarkly/ldcli/internal/sync/adapter" + "github.com/launchdarkly/ldcli/internal/sync/adapter/adaptertest" + "github.com/launchdarkly/ldcli/internal/sync/adapter/code" +) + +func TestPythonQualifiedConstantsAndDictionaryMembers(t *testing.T) { + source := fixtureFile(t, "python/settings.py") + tests := []struct { + name string + selectPath string + fieldMap map[string]string + wantPrompt string + wantModel string + }{ + { + name: "class attribute", selectPath: `constants[id="SupportSettings.instructions"]`, + fieldMap: map[string]string{"prompt": "$"}, + wantPrompt: "Ask for an order ID.\nKeep the answer concise.", + }, + { + name: "function local", selectPath: `constants[id="build.prompt"]`, + fieldMap: map[string]string{"prompt": "$"}, + wantPrompt: "Function-local prompt.", + }, + { + name: "nested dictionary", selectPath: `constants[id="CONFIG"].agents.refunds`, + fieldMap: map[string]string{"prompt": "prompt", "model": "model"}, + wantPrompt: "Explain the refund policy.", wantModel: "gpt-5", + }, + { + name: "enum dictionary key", selectPath: `constants[id="PROMPTS"]["Language.EN"]`, + fieldMap: map[string]string{"prompt": "$"}, + wantPrompt: "Answer in English.", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + id := syncdomain.VariationID("project", "config", "default") + reference := syncdomain.Reference{ + File: "settings.py", Format: "python", Select: test.selectPath, Map: test.fieldMap, + } + projection, err := adapter.ParseSource(adaptertest.Bundle(reference.File, source), id, reference) + require.NoError(t, err) + variation := projection.(syncdomain.VariationProjection) + require.NotNil(t, variation.Instructions) + assert.Equal(t, test.wantPrompt, *variation.Instructions) + if test.wantModel != "" { + require.NotNil(t, variation.Model) + assert.Equal(t, test.wantModel, *variation.Model) + } + + next := "Updated prompt 🌍" + desired := syncdomain.VariationProjection{ + Mode: syncdomain.VariationModeAgent, Instructions: &next, + } + if variation.Model != nil { + model := "gpt-5.1" + desired.Model = &model + } + result, err := adapter.UpdateSource( + adaptertest.Bundle(reference.File, source), id, reference, desired, + ) + require.NoError(t, err) + updated := adaptertest.ApplyChecked(t, source, adaptertest.DirectEdits(t, result)) + reparsed, err := adapter.ParseSource( + adaptertest.Bundle(reference.File, updated), id, reference, + ) + require.NoError(t, err) + assert.Equal(t, next, *reparsed.(syncdomain.VariationProjection).Instructions) + }) + } +} + +func TestPythonConstantLiteralKinds(t *testing.T) { + source := fixtureFile(t, "python/settings.py") + root, err := adapter.ParseDocument(adapter.Document{Path: "settings.py", Data: source}, "python") + require.NoError(t, err) + tests := []struct { + path string + want any + }{ + {path: `constants[id="SupportSettings.temperature"]`, want: 0.2}, + {path: `constants[id="SupportSettings.enabled"]`, want: true}, + {path: `constants[id="build.retries"]`, want: 3.0}, + {path: `constants[id="MODELS"]`, want: []any{"gpt-5", "claude-sonnet"}}, + {path: `constants[id="CONFIG"].agents.refunds.model`, want: "gpt-5"}, + } + for _, test := range tests { + node, err := root.Resolve(test.path) + require.NoError(t, err) + assert.Equal(t, test.want, node.Value) + } +} + +func TestCodeIndexesSelectAndUpdateConstants(t *testing.T) { + tests := []struct { + name, format, source string + }{ + { + name: "Python", format: "python", + source: "VALUES = [{\"name\": \"first\", \"prompt\": \"Keep\"}, {\"name\": \"second\", \"prompt\": \"Old\"}]\n", + }, + } + id := syncdomain.VariationID("project", "config", "default") + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + reference := syncdomain.Reference{ + File: "values", Format: test.format, Select: `constants[id="VALUES"][1]`, + Map: map[string]string{"prompt": "prompt"}, + } + projection, selections, err := adapter.ParseSourceWithSelections( + adaptertest.Bundle(reference.File, []byte(test.source)), + id, reference, + ) + require.NoError(t, err) + assert.Equal(t, "Old", *projection.(syncdomain.VariationProjection).Instructions) + require.Len(t, selections, 1) + + next := "Updated" + result, err := adapter.UpdateSource( + adaptertest.Bundle(reference.File, []byte(test.source)), + id, reference, + syncdomain.VariationProjection{Mode: syncdomain.VariationModeAgent, Instructions: &next}, + ) + require.NoError(t, err) + updated := adaptertest.ApplyChecked(t, []byte(test.source), adaptertest.DirectEdits(t, result)) + assert.Contains(t, string(updated), "Keep") + assert.Contains(t, string(updated), "Updated") + }) + } +} + +func TestDynamicDictionaryMemberDoesNotBlockLiteralModelEdit(t *testing.T) { + source := fixtureFile(t, "python/settings.py") + id := syncdomain.VariationID("project", "config", "default") + reference := syncdomain.Reference{ + File: "settings.py", Format: "python", Select: `constants[id="CONFIG"].agents.support`, + Map: map[string]string{"prompt": "prompt", "model": "model"}, + } + + projection, err := adapter.ParseSource(adaptertest.Bundle(reference.File, source), id, reference) + require.NoError(t, err) + state, ok := projection.States()[syncdomain.FieldPrompt] + require.True(t, ok) + assert.Equal(t, syncdomain.CapabilityReadOnly, state.Capability) + assert.False(t, state.Known) + + model := "gpt-5.1" + result, err := adapter.UpdateSource( + adaptertest.Bundle(reference.File, source), id, reference, + syncdomain.VariationProjection{Mode: syncdomain.VariationModeAgent, Model: &model}, + ) + require.NoError(t, err) + update := result.Fields[syncdomain.FieldModel] + assert.Equal(t, adapter.CapabilityDirect, update.Capability) + assert.Len(t, update.Edits, 1) + updated := adaptertest.ApplyChecked(t, source, update.Edits) + assert.Contains(t, string(updated), `"model": "gpt-5.1"`) + assert.Contains(t, string(updated), `f"Use {DEFAULT_MODEL} for support."`) +} + +func TestPythonLangChainPromptFormsAndDocstring(t *testing.T) { + source := fixtureFile(t, "python/langchain_prompts.py") + tests := []struct { + name string + selectPath string + mapPath string + mode syncdomain.VariationMode + }{ + {name: "from messages tuples", selectPath: `prompts[id="chat"]`, mapPath: "messages", mode: syncdomain.VariationModeCompletion}, + {name: "from messages constant", selectPath: `prompts[id="shared_chat"]`, mapPath: "messages", mode: syncdomain.VariationModeCompletion}, + {name: "constructor message objects", selectPath: `prompts[id="chat_constructor"]`, mapPath: "messages", mode: syncdomain.VariationModeCompletion}, + {name: "from template", selectPath: `prompts[id="simple"]`, mapPath: "template", mode: syncdomain.VariationModeAgent}, + {name: "template keyword", selectPath: `prompts[id="simple_keyword"]`, mapPath: "template", mode: syncdomain.VariationModeAgent}, + {name: "inline chain", selectPath: `prompts[id="chain"]`, mapPath: "messages", mode: syncdomain.VariationModeCompletion}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + id := syncdomain.VariationID("project", "config", "default") + field := "prompt" + if test.mode == syncdomain.VariationModeCompletion { + field = "messages" + } + reference := syncdomain.Reference{ + File: "langchain_prompts.py", Format: "python", Select: test.selectPath, + Map: map[string]string{field: test.mapPath}, + } + projection, err := adapter.ParseSource(adaptertest.Bundle(reference.File, source), id, reference) + require.NoError(t, err) + desired := projection + switch value := projection.(type) { + case syncdomain.VariationProjection: + if value.Messages != nil { + messages := append([]syncdomain.Message{}, (*value.Messages)...) + messages[len(messages)-1].Content = "Updated content." + value.Messages = &messages + } else { + text := "Updated template." + value.Instructions = &text + } + desired = value + } + result, err := adapter.UpdateSource(adaptertest.Bundle(reference.File, source), id, reference, desired) + require.NoError(t, err) + updated := adaptertest.ApplyChecked(t, source, adaptertest.DirectEdits(t, result)) + _, err = adapter.ParseSource(adaptertest.Bundle(reference.File, updated), id, reference) + require.NoError(t, err) + }) + } + + toolID := syncdomain.ResourceID{Kind: syncdomain.KindTool, ProjectKey: "project", LookupKey: "lookup_order"} + toolRef := syncdomain.Reference{ + File: "langchain_prompts.py", Format: "python", Select: `tools[id="lookup_order"]`, + Map: map[string]string{"description": "description", "schema": "schema"}, + } + projection, err := adapter.ParseSource(adaptertest.Bundle(toolRef.File, source), toolID, toolRef) + require.NoError(t, err) + tool := projection.(syncdomain.ToolProjection) + assert.Equal(t, "Look up one order and return its status.", *tool.Description) + schemaState, ok := tool.States()[syncdomain.FieldSchema] + require.True(t, ok) + assert.Equal(t, syncdomain.CapabilityReadOnly, schemaState.Capability) + description := "Look up an order and its current status." + result, err := adapter.UpdateSource( + adaptertest.Bundle(toolRef.File, source), toolID, toolRef, + syncdomain.ToolProjection{Description: &description}, + ) + require.NoError(t, err) + updated := adaptertest.ApplyChecked(t, source, adaptertest.DirectEdits(t, result)) + assert.Contains(t, string(updated), `"""Look up an order and its current status."""`) +} + +// A chain that holds two prompts names them with an ordinal, so the bare +// variable name matches nothing and each prompt has a key of its own. +func TestInlineChainWithTwoPromptsNumbersThem(t *testing.T) { + source := fixtureFile(t, "python/langchain_prompts.py") + id := syncdomain.VariationID("project", "config", "default") + reference := syncdomain.Reference{ + File: "langchain_prompts.py", Format: "python", Select: `prompts[id="ambiguous"]`, + Map: map[string]string{"messages": "messages"}, + } + + _, err := adapter.ParseSource(adaptertest.Bundle(reference.File, source), id, reference) + + problem, ok := syncdomain.ProblemFrom(err) + require.True(t, ok) + assert.Equal(t, syncdomain.ErrorPathNotFound, problem.Code) + reference.Select = `prompts[id="ambiguous#1"]` + _, err = adapter.ParseSource(adaptertest.Bundle(reference.File, source), id, reference) + require.NoError(t, err) +} + +func TestCompletionMessagesEditContentButRejectStructure(t *testing.T) { + source := fixtureFile(t, "python/messages.py") + id := syncdomain.VariationID("project", "config", "default") + reference := syncdomain.Reference{ + File: "messages.py", Format: "python", Select: `agents[id="completion"]`, + Map: map[string]string{"messages": "messages"}, + } + projection, err := adapter.ParseSource(adaptertest.Bundle(reference.File, source), id, reference) + require.NoError(t, err) + messages := append([]syncdomain.Message{}, (*projection.(syncdomain.VariationProjection).Messages)...) + messages[0].Content = "Updated system prompt." + messages[1].Content = "Updated user prompt." + + result, err := adapter.UpdateSource( + adaptertest.Bundle(reference.File, source), id, reference, + syncdomain.VariationProjection{Mode: syncdomain.VariationModeCompletion, Messages: &messages}, + ) + require.NoError(t, err) + update := result.Fields[syncdomain.FieldMessages] + assert.Equal(t, adapter.CapabilityDirect, update.Capability) + assert.Len(t, update.Edits, 2) + updated := adaptertest.ApplyChecked(t, source, update.Edits) + assert.Contains(t, string(updated), `"content": "Updated system prompt."`) + assert.Contains(t, string(updated), `"content": "Updated user prompt."`) + + messages = append(messages, syncdomain.Message{Role: syncdomain.RoleAssistant, Content: "Extra"}) + result, err = adapter.UpdateSource( + adaptertest.Bundle(reference.File, source), id, reference, + syncdomain.VariationProjection{Mode: syncdomain.VariationModeCompletion, Messages: &messages}, + ) + require.NoError(t, err) + assert.Equal(t, adapter.CapabilityReadOnly, result.Fields[syncdomain.FieldMessages].Capability) + assert.Contains(t, result.Fields[syncdomain.FieldMessages].Reason, "add or remove messages by hand") +} + +func TestMutationAndSpreadStayLocalToTheirFields(t *testing.T) { + pythonSource := []byte(`PROMPT = "Initial" +PROMPT += " changed" +CONFIG = {"prompt": "Initial"} +CONFIG["prompt"] = "changed" +`) + pythonRoot, err := adapter.ParseDocument(adapter.Document{Path: "settings.py", Data: pythonSource}, "python") + require.NoError(t, err) + prompt, err := pythonRoot.Resolve(`constants[id="PROMPT"]`) + require.NoError(t, err) + assert.Equal(t, adapter.CapabilityReadOnly, prompt.Capability) + assert.Contains(t, prompt.Reason, "mutated") + config, err := pythonRoot.Resolve(`constants[id="CONFIG"]`) + require.NoError(t, err) + assert.Equal(t, adapter.CapabilityReadOnly, config.Capability) + + typeScriptSource := []byte(`const CONFIG = { + description: "Static description", + ...runtimeValues, + model: helper(), +}; +CONFIG.description = "changed";`) + id := syncdomain.ResourceID{Kind: syncdomain.KindTool, ProjectKey: "project", LookupKey: "config"} + reference := syncdomain.Reference{ + File: "config.ts", Format: "typescript", Select: `constants[id="CONFIG"]`, + Map: map[string]string{"description": "description", "schema": "model"}, + } + projection, err := adapter.ParseSource(adaptertest.Bundle(reference.File, typeScriptSource), id, reference) + require.NoError(t, err) + tool := projection.(syncdomain.ToolProjection) + assert.Equal(t, "Static description", *tool.Description) + schemaState, ok := tool.States()[syncdomain.FieldSchema] + require.True(t, ok) + assert.Equal(t, syncdomain.CapabilityReadOnly, schemaState.Capability) + root, err := adapter.ParseDocument(adapter.Document{Path: "config.ts", Data: typeScriptSource}, "typescript") + require.NoError(t, err) + config, err = root.Resolve(`constants[id="CONFIG"]`) + require.NoError(t, err) + assert.Equal(t, adapter.CapabilityReadOnly, config.Capability) +} + +func TestLangChainPlaceholderTranslationRoundTrip(t *testing.T) { + source := []byte(`from langchain_core.prompts import PromptTemplate +prompt = PromptTemplate.from_template("Hello {customer}.") +`) + id := syncdomain.VariationID("project", "config", "default") + reference := syncdomain.Reference{ + File: "prompt.py", Format: "python", Select: `prompts[id="prompt"]`, + Map: map[string]string{"prompt": "template"}, + } + projection, err := adapter.ParseSource( + adaptertest.Bundle(reference.File, source), id, reference, + ) + require.NoError(t, err) + assert.Equal(t, "Hello {{customer}}.", *projection.(syncdomain.VariationProjection).Instructions) + + next := "Welcome {{customer_name}}." + result, err := adapter.UpdateSource( + adaptertest.Bundle(reference.File, source), id, reference, + syncdomain.VariationProjection{Mode: syncdomain.VariationModeAgent, Instructions: &next}, + ) + require.NoError(t, err) + updated := adaptertest.ApplyChecked(t, source, adaptertest.DirectEdits(t, result)) + assert.Contains(t, string(updated), `"Welcome {customer_name}."`) +} + +func TestAmbiguousPlaceholdersAreReadOnly(t *testing.T) { + tests := []struct { + name, template string + }{ + {name: "escaped braces", template: `Use {{literal}} braces.`}, + {name: "JSON example", template: `Return {"status":"ok"}.`}, + {name: "format conversion", template: `Show {value!r}.`}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + source := []byte("from langchain_core.prompts import PromptTemplate\nprompt = PromptTemplate.from_template(" + + strconv.Quote(test.template) + ")\n") + id := syncdomain.VariationID("project", "config", "default") + reference := syncdomain.Reference{ + File: "prompt.py", Format: "python", Select: `prompts[id="prompt"]`, + Map: map[string]string{"prompt": "template"}, + } + projection, err := adapter.ParseSource( + adaptertest.Bundle(reference.File, source), id, reference, + ) + require.NoError(t, err) + state, ok := projection.States()[syncdomain.FieldPrompt] + require.True(t, ok) + assert.Equal(t, syncdomain.CapabilityReadOnly, state.Capability) + assert.Equal(t, syncdomain.ErrorPlaceholderAmbiguous, state.Code) + }) + } +} + +func TestPythonAgentProjectionAndLiteralRoundTrip(t *testing.T) { + source := []byte(`from agents import Agent, ModelSettings + +support = Agent( + instructions='Ask for an order ID.', + model="gpt-5", + model_settings=ModelSettings(temperature=0.2), +) +`) + id := syncdomain.VariationID("project", "support", "default") + reference := syncdomain.Reference{ + File: "agents.py", Format: "python", Select: `agents[id="support"]`, + Map: map[string]string{ + "prompt": "instructions", + "model": "model", + `model.parameters["temperature"]`: "model_settings.temperature", + }, + } + projection, err := adapter.ParseSource(adaptertest.Bundle(reference.File, source), id, reference) + require.NoError(t, err) + variation := projection.(syncdomain.VariationProjection) + assert.Equal(t, "Ask for an order ID.", *variation.Instructions) + assert.Equal(t, "gpt-5", *variation.Model) + assert.Equal(t, 0.2, (*variation.Parameters)["temperature"]) + + prompt := "Ask for an order ID and \"region\"." + parameters := map[string]any{"temperature": 0.4} + update, err := adapter.UpdateSource( + adaptertest.Bundle(reference.File, source), id, reference, + syncdomain.VariationProjection{ + Mode: syncdomain.VariationModeAgent, Instructions: &prompt, + Model: variation.Model, Parameters: ¶meters, + FieldStates: map[syncdomain.Field]syncdomain.FieldState{ + `model.parameters["temperature"]`: {Capability: syncdomain.CapabilityDirect, Known: true}, + }, + }, + ) + require.NoError(t, err) + assert.Equal(t, adapter.CapabilityDirect, update.Fields[syncdomain.FieldPrompt].Capability) + assert.Equal(t, adapter.CapabilityDirect, update.Fields[`model.parameters["temperature"]`].Capability) +} + +func TestPythonToolDescriptionFromStaticDocstring(t *testing.T) { + source := []byte(`from agents import function_tool + +@function_tool +def lookup_order(order_id: str): + """Look up one order.""" + return order_id +`) + id := syncdomain.ResourceID{Kind: syncdomain.KindTool, ProjectKey: "project", LookupKey: "lookup-order"} + reference := syncdomain.Reference{ + File: "tools.py", Format: "python", Select: `tools[id="lookup_order"]`, + Map: map[string]string{"description": "description"}, + } + + projection, err := adapter.ParseSource(adaptertest.Bundle(reference.File, source), id, reference) + + require.NoError(t, err) + assert.Equal(t, "Look up one order.", *projection.(syncdomain.ToolProjection).Description) +} + +func TestPythonPromptUsesImmutableSameFileConstant(t *testing.T) { + source := []byte(`from langchain_core.prompts import PromptTemplate + +TEMPLATE = """Summarize the case. +Keep the answer short.""" +summary = PromptTemplate(template=TEMPLATE) +`) + id := syncdomain.VariationID("project", "summary", "default") + reference := syncdomain.Reference{ + File: "prompts.py", Format: "python", Select: `prompts[id="summary"]`, + Map: map[string]string{"prompt": "template"}, + } + + projection, err := adapter.ParseSource(adaptertest.Bundle(reference.File, source), id, reference) + + require.NoError(t, err) + assert.Equal(t, "Summarize the case.\nKeep the answer short.", + *projection.(syncdomain.VariationProjection).Instructions) +} + +func FuzzPythonStringRoundTrip(f *testing.F) { + for _, value := range []string{"plain", "quotes ' \" and slash \\", "line one\nline two", "Unicode 🌍"} { + f.Add(value) + } + f.Fuzz(func(t *testing.T, value string) { + if !utf8.ValidString(value) { + t.Skip() + } + for _, quote := range []string{"'", `"`, `"""`} { + encoded := code.EncodePythonString(value, quote) + decoded, _, err := code.DecodePythonString(encoded) + require.NoError(t, err) + require.Equal(t, value, decoded) + } + }) +} + +func TestDecoratedResourcesHaveStableIDs(t *testing.T) { + source := []byte(` +from langchain_core.tools import tool + +@tool +def lookup(order_id: str): + """Look up an order.""" + return order_id + +@tool +def search(query: str): + """Search the docs.""" + return query +`) + id := syncdomain.ResourceID{Kind: syncdomain.KindTool, ProjectKey: "project", LookupKey: "search"} + reference := syncdomain.Reference{ + File: "tools.py", Format: "python", Select: `tools[id="search"]`, + Map: map[string]string{"description": "description"}, + } + + projection, selections, err := adapter.ParseSourceWithSelections(adaptertest.Bundle("tools.py", source), id, reference) + + require.NoError(t, err) + assert.Equal(t, "Search the docs.", *projection.(syncdomain.ToolProjection).Description) + assert.Empty(t, selections, "a function name is a stable identity, not a position") +} diff --git a/internal/sync/adapter/all/structured_test.go b/internal/sync/adapter/all/structured_test.go new file mode 100644 index 000000000..3edfc8dfb --- /dev/null +++ b/internal/sync/adapter/all/structured_test.go @@ -0,0 +1,544 @@ +package all + +import ( + "encoding/json" + "testing" + "unicode/utf8" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" + "github.com/launchdarkly/ldcli/internal/sync/adapter" + "github.com/launchdarkly/ldcli/internal/sync/adapter/adaptertest" + "github.com/launchdarkly/ldcli/internal/sync/adapter/yamltree" +) + +func TestYAMLPlainScalarSpansCoverCommasAndBrackets(t *testing.T) { + source := []byte("agents:\n - name: support\n prompt: Ask for an order ID, then the region [and date]\n tags: [one, two]\n") + root, err := adapter.ParseDocument(adapter.Document{Path: "agents.yaml", Data: source}, "yaml") + require.NoError(t, err) + + prompt, err := root.Resolve(`agents[name="support"].prompt`) + require.NoError(t, err) + assert.Equal(t, "Ask for an order ID, then the region [and date]", string(source[prompt.Start:prompt.End])) + assert.Equal(t, adapter.CapabilityDirect, prompt.Capability) + + tag, err := root.Resolve(`agents[name="support"].tags[0]`) + require.NoError(t, err) + assert.Equal(t, "one", string(source[tag.Start:tag.End])) + + folded := []byte("prompt: first line\n second line\n") + root, err = adapter.ParseDocument(adapter.Document{Path: "prompt.yaml", Data: folded}, "yaml") + require.NoError(t, err) + value, err := root.Resolve("prompt") + require.NoError(t, err) + assert.Equal(t, adapter.CapabilityReadOnly, value.Capability) +} + +func TestJSONIndividualModelParameterMappingsRoundTrip(t *testing.T) { + tests := []struct { + name string + key string + source any + replacement any + }{ + {name: "temperature", key: "temperature", source: 0.2, replacement: 0.7}, + {name: "top k", key: "top_k", source: 20, replacement: 40}, + {name: "top p", key: "top_p", source: 0.9, replacement: 0.8}, + {name: "max tokens", key: "max_tokens", source: 256, replacement: 512}, + {name: "future provider key", key: "Provider.Future-Key", source: "v1", replacement: "v2"}, + {name: "string", key: "response_style", source: "brief", replacement: "detailed"}, + {name: "boolean", key: "reasoning", source: true, replacement: false}, + {name: "null", key: "seed", source: nil, replacement: true}, + { + name: "nested object", key: "provider", + source: map[string]any{"mode": "fast"}, + replacement: map[string]any{"mode": "careful", "retry": true}, + }, + { + name: "array", key: "stop", + source: []any{"END"}, + replacement: []any{"DONE", map[string]any{"token": 2}}, + }, + } + id := syncdomain.VariationID("project", "config", "default") + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + source, err := json.Marshal(map[string]any{"prompt": "Help", "value": test.source}) + require.NoError(t, err) + target := adaptertest.ModelParameterTarget(test.key) + reference := syncdomain.Reference{ + File: "agent.json", Format: "json", + Map: map[string]string{"prompt": "prompt", target: "value"}, + } + + projection, err := adapter.ParseSource( + adaptertest.Bundle(reference.File, source), id, reference, + ) + require.NoError(t, err) + value, present := projection.Value(syncdomain.Field(target)) + assert.True(t, present) + adaptertest.AssertJSONEqual(t, test.source, value) + + parameters := map[string]any{test.key: test.replacement} + update, err := adapter.UpdateSource( + adaptertest.Bundle(reference.File, source), id, reference, + syncdomain.VariationProjection{ + Mode: syncdomain.VariationModeAgent, Parameters: ¶meters, + FieldStates: map[syncdomain.Field]syncdomain.FieldState{ + syncdomain.Field(target): {Capability: syncdomain.CapabilityDirect, Known: true}, + }, + }, + ) + require.NoError(t, err) + require.Len(t, update.Fields[syncdomain.Field(target)].Edits, 1) + updated := adaptertest.ApplyEdits(source, update.Fields[syncdomain.Field(target)].Edits...) + reparsed, err := adapter.ParseSource( + adaptertest.Bundle(reference.File, updated), id, reference, + ) + require.NoError(t, err) + value, present = reparsed.Value(syncdomain.Field(target)) + assert.True(t, present) + adaptertest.AssertJSONEqual(t, test.replacement, value) + }) + } +} + +func TestJSONWholeModelParametersOwnsCanonicalMap(t *testing.T) { + source := []byte(`{"prompt":"Help","parameters":{"temperature":0.2,"tools":[{"name":"search"}]}}`) + id := syncdomain.VariationID("project", "config", "default") + reference := syncdomain.Reference{ + File: "agent.json", Format: "json", + Map: map[string]string{"prompt": "prompt", "model.parameters": "parameters"}, + } + projection, err := adapter.ParseSource( + adaptertest.Bundle(reference.File, source), id, reference, + ) + require.NoError(t, err) + parameters := projection.(syncdomain.VariationProjection).Parameters + require.NotNil(t, parameters) + assert.Equal(t, map[string]any{"temperature": 0.2}, *parameters) + + replacement := map[string]any{ + "top_k": 30, + "nested": map[string]any{"enabled": true}, + "stop": []any{"END"}, + "seed": nil, + } + update, err := adapter.UpdateSource( + adaptertest.Bundle(reference.File, source), id, reference, + syncdomain.VariationProjection{ + Mode: syncdomain.VariationModeAgent, Parameters: &replacement, + FieldStates: map[syncdomain.Field]syncdomain.FieldState{ + syncdomain.FieldModelParameters: {Capability: syncdomain.CapabilityDirect, Known: true}, + }, + }, + ) + require.NoError(t, err) + require.Len(t, update.Fields[syncdomain.FieldModelParameters].Edits, 1) + updated := adaptertest.ApplyEdits(source, update.Fields[syncdomain.FieldModelParameters].Edits...) + assert.JSONEq(t, `{"prompt":"Help","parameters":{"top_k":30,"nested":{"enabled":true},"stop":["END"],"seed":null}}`, string(updated)) +} + +func TestStructuredPathsReadNestedListsAndLiteralKeys(t *testing.T) { + tests := []struct { + name string + format string + source string + path string + want any + }{ + { + name: "JSON keyed list", format: "json", + source: `{"items":[{"name":"one","enabled":false},{"name":"two","enabled":true}]}`, + path: `items[name="two"].enabled`, want: true, + }, + { + name: "YAML nested field", format: "yaml", + source: "agent:\n model:\n temperature: 0.3\n", + path: "agent.model.temperature", want: 0.3, + }, + { + name: "TOML literal key", format: "toml", + source: "[metadata]\n\"display.name\" = \"Support\"\n", + path: `metadata["display.name"]`, want: "Support", + }, + { + name: "TOML inline table list", format: "toml", + source: "items = [{ name = \"one\", prompt = \"First\" }, { name = \"two\", prompt = \"Second\" }]\n", + path: `items[name="two"].prompt`, want: "Second", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root, err := adapter.ParseDocument(adapter.Document{Path: "source", Data: []byte(test.source)}, test.format) + require.NoError(t, err) + value, err := root.Resolve(test.path) + require.NoError(t, err) + assert.Equal(t, test.want, value.Value) + }) + } +} + +func FuzzJSONStringCodec(f *testing.F) { + for _, value := range []string{"plain", "quotes \" and slash \\", "line one\nline two", "Unicode 🌍"} { + f.Add(value) + } + f.Fuzz(func(t *testing.T, value string) { + if !utf8.ValidString(value) { + t.Skip() + } + quoted, err := json.Marshal(value) + require.NoError(t, err) + source := append([]byte(`{"value":`), quoted...) + source = append(source, '}') + root, err := adapter.ParseDocument(adapter.Document{Path: "value.json", Data: source}, "json") + require.NoError(t, err) + node, err := root.Resolve("value") + require.NoError(t, err) + encoded, err := node.Encode(node.Value) + require.NoError(t, err) + var decoded string + require.NoError(t, json.Unmarshal(encoded, &decoded)) + require.Equal(t, value, decoded) + }) +} + +func FuzzYAMLStringCodec(f *testing.F) { + for _, value := range []string{"plain", "quote ' and slash \\", "line one\nline two", "Unicode 🌍"} { + f.Add(value) + } + f.Fuzz(func(t *testing.T, value string) { + if !utf8.ValidString(value) { + t.Skip() + } + for _, previous := range []string{"plain", "'plain'", `"plain"`} { + encoded, err := yamltree.EncodeString(value, previous) + if err != nil { + t.Skip() + } + source := append([]byte("value: "), encoded...) + source = append(source, '\n') + root, err := adapter.ParseDocument(adapter.Document{Path: "value.yaml", Data: source}, "yaml") + require.NoError(t, err) + node, err := root.Resolve("value") + require.NoError(t, err) + require.Equal(t, value, node.Value) + } + }) +} + +func TestStructuredAdaptersRejectDuplicateKeys(t *testing.T) { + tests := []struct { + name string + format string + source string + }{ + {name: "JSON", format: "json", source: `{"name":"first","name":"second"}`}, + {name: "YAML", format: "yaml", source: "name: first\nname: second\n"}, + {name: "TOML", format: "toml", source: "name = \"first\"\nname = \"second\"\n"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := adapter.ParseDocument(adapter.Document{Path: "source", Data: []byte(test.source)}, test.format) + require.Error(t, err) + problem, ok := syncdomain.ProblemFrom(err) + require.True(t, ok) + assert.Equal(t, syncdomain.ErrorDuplicateKey, problem.Code) + }) + } +} + +func TestStructuredAdaptersRejectInvalidSyntax(t *testing.T) { + tests := []struct { + name, format, source string + }{ + {name: "empty JSON", format: "json", source: ""}, + {name: "JSON trailing input", format: "json", source: `{"a":1} true`}, + {name: "JSON missing colon", format: "json", source: `{"a" 1}`}, + {name: "JSON missing comma", format: "json", source: `[1 2]`}, + {name: "JSON unclosed string", format: "json", source: `{"a":"value}`}, + {name: "invalid YAML", format: "yaml", source: "value: [\n"}, + {name: "empty YAML", format: "yaml", source: ""}, + {name: "invalid TOML", format: "toml", source: "value = [\n"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := adapter.ParseDocument( + adapter.Document{Path: "source", Data: []byte(test.source)}, test.format, + ) + require.Error(t, err) + problem, ok := syncdomain.ProblemFrom(err) + require.True(t, ok) + assert.Equal(t, syncdomain.ErrorInvalidSource, problem.Code) + }) + } +} + +func TestYAMLUnsafeFormsAreReadOnly(t *testing.T) { + tests := []struct { + name string + source string + path string + }{ + {name: "block scalar", source: "prompt: |\n Help the user.\n", path: "prompt"}, + {name: "anchor", source: "prompt: &shared \"Help\"\n", path: "prompt"}, + {name: "alias", source: "base: &shared \"Help\"\nprompt: *shared\n", path: "prompt"}, + {name: "merge", source: "base: &base\n prompt: Help\nagent:\n <<: *base\n", path: "agent.prompt"}, + {name: "tag", source: "prompt: !custom Help\n", path: "prompt"}, + {name: "multiple documents", source: "prompt: First\n---\nprompt: Second\n", path: "prompt"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root, err := adapter.ParseDocument(adapter.Document{Path: "agents.yaml", Data: []byte(test.source)}, "yaml") + require.NoError(t, err) + value, err := root.Resolve(test.path) + require.NoError(t, err) + assert.Equal(t, adapter.CapabilityReadOnly, value.Capability) + assert.NotEmpty(t, value.Reason) + }) + } +} + +func TestTOMLArrayTableFieldsAreReadOnly(t *testing.T) { + source := `[[tool.plugins]] +name = "audit" +enabled = true +` + root, err := adapter.ParseDocument(adapter.Document{Path: "pyproject.toml", Data: []byte(source)}, "toml") + require.NoError(t, err) + + value, err := root.Resolve(`tool.plugins[name="audit"].enabled`) + + require.NoError(t, err) + assert.Equal(t, adapter.CapabilityReadOnly, value.Capability) +} + +func TestStructuredEditsPreserveBOMAndCRLFOutsideSpan(t *testing.T) { + source := []byte("\ufeff{\r\n \"description\": \"Old\",\r\n \"enabled\": true\r\n}\r\n") + id := syncdomain.ResourceID{Kind: syncdomain.KindTool, ProjectKey: "project", LookupKey: "tool"} + reference := syncdomain.Reference{ + File: "tool.json", Format: "json", Select: "$", + Map: map[string]string{"description": "description"}, + } + description := "New 🌍" + + result, err := adapter.UpdateSource( + adaptertest.Bundle(reference.File, source), id, reference, + syncdomain.ToolProjection{Description: &description}, + ) + + require.NoError(t, err) + updated := adaptertest.ApplyChecked(t, source, adaptertest.DirectEdits(t, result)) + assert.Contains(t, string(updated), "\ufeff{\r\n") + assert.Contains(t, string(updated), "\"enabled\": true\r\n") + assert.Contains(t, string(updated), `"description": "New 🌍"`) +} + +func TestStructuredIndexWriteRoundTripsPreserveOtherItems(t *testing.T) { + tests := []struct { + name, format, source, unchanged string + }{ + { + name: "JSON", format: "json", + source: "{\n \"tools\": [{\"name\":\"first\",\"description\":\"Keep\"}, {\"name\":\"second\",\"description\":\"Old\"}]\n}\n", + unchanged: `{"name":"first","description":"Keep"}`, + }, + { + name: "YAML", format: "yaml", + source: "tools:\n - {name: first, description: Keep}\n - {name: second, description: Old}\n", + unchanged: "{name: first, description: Keep}", + }, + { + name: "TOML", format: "toml", + source: "tools = [{ name = \"first\", description = \"Keep\" }, { name = \"second\", description = \"Old\" }]\n", + unchanged: `{ name = "first", description = "Keep" }`, + }, + } + id := syncdomain.ResourceID{Kind: syncdomain.KindTool, ProjectKey: "project", LookupKey: "second"} + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + source := []byte(test.source) + reference := syncdomain.Reference{ + File: "tools." + test.format, Format: test.format, Select: "tools[1]", + Map: map[string]string{"description": "description"}, + } + projection, selections, err := adapter.ParseSourceWithSelections(adaptertest.Bundle(reference.File, source), id, reference) + require.NoError(t, err) + assert.Equal(t, "Old", *projection.(syncdomain.ToolProjection).Description) + require.Len(t, selections, 1) + assert.Equal(t, 2, selections[0].CollectionLength) + + description := "Updated" + update, err := adapter.UpdateSource( + adaptertest.Bundle(reference.File, source), id, reference, + syncdomain.ToolProjection{Description: &description}, + ) + require.NoError(t, err) + updated := adaptertest.ApplyChecked(t, source, adaptertest.DirectEdits(t, update)) + assert.Contains(t, string(updated), test.unchanged) + assert.Contains(t, string(updated), "Updated") + + roundTrip, _, err := adapter.ParseSourceWithSelections( + adaptertest.Bundle(reference.File, updated), id, reference, + ) + require.NoError(t, err) + assert.Equal(t, description, *roundTrip.(syncdomain.ToolProjection).Description) + }) + } +} + +func TestIndexedSelectionsDetectNestedReorders(t *testing.T) { + id := syncdomain.VariationID("project", "completion", "default") + reference := syncdomain.Reference{ + File: "agents.json", Format: "json", Select: "agents[0]", + Map: map[string]string{"prompt": "messages[0].content"}, + } + before := []byte(`{"agents":[ + {"id":"support","messages":[{"role":"system","content":"Help"},{"role":"user","content":"Example"}]}, + {"id":"other","messages":[{"role":"system","content":"Other"}]} + ]}`) + after := []byte(`{"agents":[ + {"id":"other","messages":[{"role":"system","content":"Other"}]}, + {"id":"support","messages":[{"role":"user","content":"Example"},{"role":"system","content":"Help"}]} + ]}`) + + _, baseline, err := adapter.ParseSourceWithSelections( + adaptertest.Bundle(reference.File, before), id, reference, + ) + require.NoError(t, err) + _, reordered, err := adapter.ParseSourceWithSelections( + adaptertest.Bundle(reference.File, after), id, reference, + ) + require.NoError(t, err) + + require.Len(t, baseline, 2) + require.Len(t, reordered, 2) + assert.NotEqual(t, baseline, reordered) +} + +func TestYAMLUnicodeColumnsAndFlowScalarsUseExactSpans(t *testing.T) { + source := []byte("étiquette: {prompt: ancien, enabled: true}\n") + id := syncdomain.VariationID("project", "config", "default") + reference := syncdomain.Reference{ + File: "agents.yaml", Format: "yaml", Select: `["étiquette"]`, + Map: map[string]string{"prompt": "prompt"}, + } + prompt := "nouveau" + + result, err := adapter.UpdateSource( + adaptertest.Bundle(reference.File, source), id, reference, + syncdomain.VariationProjection{Mode: syncdomain.VariationModeAgent, Instructions: &prompt}, + ) + + require.NoError(t, err) + updated := adaptertest.ApplyChecked(t, source, adaptertest.DirectEdits(t, result)) + assert.Equal(t, "étiquette: {prompt: nouveau, enabled: true}\n", string(updated)) +} + +func TestStructuredValueFormsRoundTrip(t *testing.T) { + tests := []struct { + name string + format string + source string + path string + want any + next any + }{ + {name: "JSON escaped Unicode string", format: "json", source: `{"value":"Hello \u4e16\u754c"}`, path: "value", want: "Hello 世界", next: "Hello 🌍"}, + {name: "JSON number", format: "json", source: `{"value":1.25}`, path: "value", want: 1.25, next: 2.5}, + {name: "JSON boolean", format: "json", source: `{"value":true}`, path: "value", want: true, next: false}, + {name: "JSON object", format: "json", source: `{"value":{"enabled":true}}`, path: "value", want: map[string]any{"enabled": true}, next: map[string]any{"enabled": false}}, + {name: "YAML plain string", format: "yaml", source: "value: hello\n", path: "value", want: "hello", next: "updated"}, + {name: "YAML single quoted string", format: "yaml", source: "value: 'hello'\n", path: "value", want: "hello", next: "updated"}, + {name: "YAML double quoted string", format: "yaml", source: "value: \"hello\\nworld\"\n", path: "value", want: "hello\nworld", next: "updated\nworld"}, + {name: "YAML number", format: "yaml", source: "value: 1.25\n", path: "value", want: 1.25, next: 2.5}, + {name: "YAML boolean", format: "yaml", source: "value: true\n", path: "value", want: true, next: false}, + {name: "TOML basic string", format: "toml", source: "value = \"hello\"\n", path: "value", want: "hello", next: "updated"}, + {name: "TOML literal string", format: "toml", source: "value = 'hello'\n", path: "value", want: "hello", next: "updated"}, + {name: "TOML multiline string", format: "toml", source: "value = \"\"\"hello\nworld\"\"\"\n", path: "value", want: "hello\nworld", next: "updated\nworld"}, + {name: "TOML number", format: "toml", source: "value = 1.25\n", path: "value", want: 1.25, next: 2.5}, + {name: "TOML boolean", format: "toml", source: "value = true\n", path: "value", want: true, next: false}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + source := []byte(test.source) + root, err := adapter.ParseDocument(adapter.Document{Path: "source", Data: source}, test.format) + require.NoError(t, err) + node, err := root.Resolve(test.path) + require.NoError(t, err) + assert.Equal(t, test.want, node.Value) + require.Equal(t, adapter.CapabilityDirect, node.Capability) + replacement, err := node.Encode(test.next) + require.NoError(t, err) + updated := append(append(append([]byte{}, source[:node.Start]...), replacement...), source[node.End:]...) + root, err = adapter.ParseDocument(adapter.Document{Path: "source", Data: updated}, test.format) + require.NoError(t, err) + node, err = root.Resolve(test.path) + require.NoError(t, err) + assert.Equal(t, test.next, node.Value) + }) + } +} + +func TestStructuredProjectionCollectionsAndObjects(t *testing.T) { + variationSource := []byte(`{ + "prompt": "Help", + "tools": ["source-tool"], + "skills": ["source-skill"] +}`) + variationID := syncdomain.VariationID("project", "config", "default") + tools := []syncdomain.AttachmentRef{{Key: "lookup"}} + skills := []syncdomain.AttachmentRef{{Key: "refunds"}} + variationRef := syncdomain.Reference{ + File: "variation.json", Format: "json", Select: "$", + Map: map[string]string{"prompt": "prompt"}, + Tools: &tools, Skills: &skills, + } + projection, err := adapter.ParseSource( + adaptertest.Bundle(variationRef.File, variationSource), variationID, variationRef, + ) + require.NoError(t, err) + variation := projection.(syncdomain.VariationProjection) + assert.Equal(t, []string{"lookup"}, *variation.Tools) + assert.Equal(t, []string{"refunds"}, *variation.Skills) + + toolSource := []byte(`{ + "description": "Lookup", + "schema": {"type": "object"}, + "customParameters": {"timeout": 5}, + "tags": ["orders", "support"] +}`) + toolID := syncdomain.ResourceID{Kind: syncdomain.KindTool, ProjectKey: "project", LookupKey: "lookup"} + toolRef := syncdomain.Reference{ + File: "tool.json", Format: "json", Select: "$", + Map: map[string]string{ + "description": "description", "schema": "schema", + "customParameters": "customParameters", "tags": "tags", + }, + } + projection, err = adapter.ParseSource(adaptertest.Bundle(toolRef.File, toolSource), toolID, toolRef) + require.NoError(t, err) + tool := projection.(syncdomain.ToolProjection) + assert.Equal(t, "object", (*tool.Schema)["type"]) + assert.Equal(t, float64(5), (*tool.CustomParameters)["timeout"]) + assert.Equal(t, []string{"orders", "support"}, *tool.Tags) +} + +func TestMapTypeMismatchUsesStableCode(t *testing.T) { + id := syncdomain.VariationID("project", "config", "default") + reference := syncdomain.Reference{ + File: "value.json", Format: "json", Select: "$", + Map: map[string]string{"prompt": "prompt"}, + } + + _, err := adapter.ParseSource( + adaptertest.Bundle(reference.File, []byte(`{"prompt":42}`)), + id, reference, + ) + + problem, ok := syncdomain.ProblemFrom(err) + require.True(t, ok) + assert.Equal(t, syncdomain.ErrorMapTypeMismatch, problem.Code) +} diff --git a/internal/sync/adapter/ecmascript/ecmascript.go b/internal/sync/adapter/ecmascript/ecmascript.go new file mode 100644 index 000000000..f6d569948 --- /dev/null +++ b/internal/sync/adapter/ecmascript/ecmascript.go @@ -0,0 +1,2279 @@ +// Package ecmascript inspects TypeScript and JavaScript sources. It finds +// agents, prompts, tools, and constants through an import allowlist and never +// runs the code. +package ecmascript + +import ( + "fmt" + "maps" + "math" + "path" + "slices" + "strconv" + "strings" + + sitter "github.com/tree-sitter/go-tree-sitter" + javascript "github.com/tree-sitter/tree-sitter-javascript/bindings/go" + typescript "github.com/tree-sitter/tree-sitter-typescript/bindings/go" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" + "github.com/launchdarkly/ldcli/internal/sync/adapter" + "github.com/launchdarkly/ldcli/internal/sync/adapter/code" +) + +type ecmaScriptAdapter struct { + format string + typed bool +} + +func (adapter ecmaScriptAdapter) Format() string { return adapter.format } +func (adapter ecmaScriptAdapter) Behavior() string { return adapter.format + "/1" } + +func (adapter ecmaScriptAdapter) Inspect(bundle adapter.SourceBundle) (*adapter.Node, error) { + return parseTypeScriptBundle(bundle, adapter.typed) +} + +func (ecmaScriptAdapter) Defaults(syncdomain.Kind) (string, map[string]string) { return "", nil } + +func (adapter ecmaScriptAdapter) Dependencies(document adapter.Document) ([]string, error) { + tree, err := code.ParseTree(document, typeScriptLanguage(document.Path, adapter.typed)) + if err != nil { + return nil, err + } + defer tree.Close() + var dependencies []string + for module := range typeScriptImportedModules(tree.RootNode(), document.Data) { + dependencies = append(dependencies, typeScriptModulePaths(document.Path, module)...) + } + return dependencies, nil +} + +func init() { + adapter.MustRegister(ecmaScriptAdapter{format: "typescript", typed: true}) + adapter.MustRegister(ecmaScriptAdapter{format: "javascript"}) +} + +func parseTypeScriptBundle(bundle adapter.SourceBundle, typed bool) (*adapter.Node, error) { + return parseTypeScript(bundle.Primary, typed, typeScriptImportedValues(bundle, typed)) +} + +// typeScriptLanguage returns the grammar of a file. JSX is a syntax error in +// the TypeScript grammar, so a .tsx file uses the TSX grammar. A JavaScript +// file uses the JavaScript grammar, which includes JSX. +func typeScriptLanguage(file string, typed bool) *sitter.Language { + switch strings.ToLower(path.Ext(file)) { + case ".tsx": + return sitter.NewLanguage(typescript.LanguageTSX()) + case ".ts", ".mts", ".cts": + return sitter.NewLanguage(typescript.LanguageTypescript()) + case ".js", ".jsx", ".mjs", ".cjs": + return sitter.NewLanguage(javascript.Language()) + } + if typed { + return sitter.NewLanguage(typescript.LanguageTypescript()) + } + return sitter.NewLanguage(javascript.Language()) +} + +func parseTypeScript(document adapter.Document, typed bool, imported map[string]*adapter.Node) (*adapter.Node, error) { + tree, err := code.ParseTree(document, typeScriptLanguage(document.Path, typed)) + if err != nil { + return nil, err + } + defer tree.Close() + + imports := typeScriptImports(tree.RootNode(), document.Data) + instances := typeScriptInstances(tree.RootNode(), document.Data, imports, typeScriptTypeImports(tree.RootNode(), document.Data)) + constants, constantResources := typeScriptConstants(tree.RootNode(), document.Data, imported) + for name, value := range imported { + if _, shadowed := constants[name]; !shadowed { + constants[name] = value + } + } + collections := map[string][]*adapter.Node{"agents": {}, "prompts": {}, "tools": {}, "constants": {}} + collections["constants"] = constantResources + fallbacks := code.NewFallbackIDs() + code.Walk(tree.RootNode(), func(node *sitter.Node) { + name, value := typeScriptDeclaration(node) + if name == nil || value == nil { + return + } + id := typeScriptQualifiedID(node, code.Text(name, document.Data), document.Data) + // One prompt under the declaration, alone or piped into a chain, takes + // the variable name. Several take the name with an ordinal, so that + // each has a key of its own. + calls := typeScriptPromptCalls(value, imports, document.Data) + for index, call := range calls { + fields := typeScriptPromptFields(call, imports, document.Data, constants) + resource := adapter.Resource(id, fields) + if len(calls) > 1 { + // The ordinal is the position of the prompt in the chain. + resource.Fields["id"] = adapter.PositionalID(fmt.Sprintf("%s#%d", id, index+1)) + } + collections["prompts"] = append(collections["prompts"], resource) + } + if collection, resource, ok := typeScriptFrameworkCall( + value, id, imports, instances, document.Data, constants, + ); ok { + collections[collection] = append(collections[collection], resource) + } + if value.Kind() == "object" && (id == "tools" || strings.HasSuffix(id, ".tools")) { + // `const tools = { search: tool({...}) }` of the Vercel AI SDK. A + // tools object inside a function names its tools under the function. + prefix := strings.TrimSuffix(id, "tools") + collections["tools"] = append(collections["tools"], typeScriptVercelTools(value, prefix, imports, document.Data, constants)...) + } + }) + code.Walk(tree.RootNode(), func(node *sitter.Node) { + // `generateText({ tools: { search: tool({...}) } })` holds its tools + // inline. The enclosing scopes name them, as a tools constant does. + if node.Kind() != "pair" { + return + } + key, ok := typeScriptPropertyName(node.ChildByFieldName("key"), document.Data) + value := typeScriptUnwrap(node.ChildByFieldName("value")) + if !ok || key != "tools" || value == nil || value.Kind() != "object" { + return + } + prefix := "" + if scopes := typeScriptScopes(node, document.Data); len(scopes) != 0 { + prefix = strings.Join(scopes, ".") + "." + } + collections["tools"] = append(collections["tools"], typeScriptVercelTools(value, prefix, imports, document.Data, constants)...) + }) + code.Walk(tree.RootNode(), func(node *sitter.Node) { + if node.Kind() != "call_expression" && node.Kind() != "new_expression" { + return + } + // The declarator walk named the call that is the whole value of a + // declaration, with or without await. A call nested in an array or + // in another call, such as an agent in a team, is a resource of its own. + if !typeScriptAssignedValue(node) { + if collection, resource, ok := typeScriptFrameworkCall( + node, "", imports, instances, document.Data, constants, + ); ok { + fallbacks.Distinguish(resource, typeScriptQualifiedID(node, typeScriptCallable(node, document.Data), document.Data)) + collections[collection] = append(collections[collection], resource) + } + } + // A prompt that a function returns, or builds in place, has no + // variable name, so the enclosing function names it. + if !typeScriptAssignedCall(node) && typeScriptPromptCall(node, imports, document.Data) { + callable := typeScriptCallable(node, document.Data) + fields := typeScriptPromptFields(node, imports, document.Data, constants) + id := typeScriptQualifiedID(node, callable, document.Data) + resource := adapter.Resource(id, fields) + fallbacks.Distinguish(resource, id) + collections["prompts"] = append(collections["prompts"], resource) + } + }) + // Two resources of one collection with one id, such as the tools of two + // inline tools objects in one scope, get ordinals. + for _, name := range []string{"agents", "prompts", "tools"} { + code.DistinguishDuplicates(collections[name]) + } + return adapter.Collections(collections), nil +} + +// typeScriptDeclaration returns the name and the unwrapped value of a +// declaration that names its value: a variable declarator or a class field. +// An assignment to an existing name is not one, so the call walk names the +// calls under it. +func typeScriptDeclaration(node *sitter.Node) (*sitter.Node, *sitter.Node) { + switch node.Kind() { + case "variable_declarator", "public_field_definition": + return node.ChildByFieldName("name"), typeScriptUnwrap(node.ChildByFieldName("value")) + case "field_definition": + return node.ChildByFieldName("property"), typeScriptUnwrap(node.ChildByFieldName("value")) + } + return nil, nil +} + +// typeScriptVercelTools reads the Vercel AI SDK tools of a tools object: each +// entry whose value is a `tool({...})` call, named by its key under prefix. +func typeScriptVercelTools( + object *sitter.Node, + prefix string, + imports typeScriptAliases, + source []byte, + constants map[string]*adapter.Node, +) []*adapter.Node { + var tools []*adapter.Node + for _, pair := range code.NamedChildren(object) { + if pair.Kind() != "pair" { + continue + } + key, ok := typeScriptPropertyName(pair.ChildByFieldName("key"), source) + call := typeScriptUnwrap(pair.ChildByFieldName("value")) + if !ok || call == nil || call.Kind() != "call_expression" || + imports.at(call, source)[typeScriptCallable(call, source)] != "vercel-tool" { + continue + } + arguments := code.NamedChildren(call.ChildByFieldName("arguments")) + if len(arguments) != 1 { + continue + } + fields := typeScriptObject(arguments[0], source, constants).Fields + tools = append(tools, adapter.Resource(prefix+key, fields)) + } + return tools +} + +// typeScriptAssignedValue reports whether a call is the whole value of a +// declaration, which the declaration walk names after its variable. +func typeScriptAssignedValue(call *sitter.Node) bool { + for parent := call.Parent(); parent != nil; parent = parent.Parent() { + if name, value := typeScriptDeclaration(parent); name != nil { + return code.SameNode(value, call) + } + switch parent.Kind() { + case "as_expression", "satisfies_expression", "non_null_expression", "parenthesized_expression", + "await_expression", "type_assertion": + continue + default: + return false + } + } + return false +} + +// typeScriptAssignedCall reports whether a call sits anywhere under a +// declaration. The declaration walk names every prompt under the value of a +// declaration, through the body of an assigned function as well, so the +// call walk leaves them out. +func typeScriptAssignedCall(call *sitter.Node) bool { + for parent := call.Parent(); parent != nil; parent = parent.Parent() { + if name, value := typeScriptDeclaration(parent); name != nil { + // A call in a function or a class nested in the value has a + // scope of its own, so the call walk names it. + return value != nil && !typeScriptInnerBinding(call, value) + } + } + return false +} + +// typeScriptConstants returns the literal constants of a file by qualified +// id, and the constant resources that constants[id="NAME"] selects. imported +// holds the values of the static imports. A literal can use an imported +// value or a constant of the same file that is declared before it. +func typeScriptConstants(root *sitter.Node, source []byte, imported map[string]*adapter.Node) (map[string]*adapter.Node, []*adapter.Node) { + type declaration struct { + id string + value *sitter.Node + } + var declarations []declaration + counts := map[string]int{} + code.Walk(root, func(node *sitter.Node) { + // `exports.TEXT = "Hello"`, an entry of `module.exports` with a + // literal, and `export = "Hello"` declare the constant that a + // require of the module binds. + for _, export := range typeScriptModuleExports(node, source) { + if export.value != nil { + id := typeScriptQualifiedID(node, export.local, source) + declarations = append(declarations, declaration{id: id, value: export.value}) + counts[id]++ + } + } + // `export default value` and `export { TEXT as default }` are the + // constant that a default import binds. + if node.Kind() == "export_statement" { + if valueNode := typeScriptDefaultExport(node, source); valueNode != nil { + declarations = append(declarations, declaration{id: "default", value: valueNode}) + counts["default"]++ + } + return + } + // A parameter, a catch parameter, or a local function or class hides + // a constant of the same name in its function, so it has an entry + // without a value. + if pattern := typeScriptLocalBinding(node); pattern != nil { + for _, bound := range typeScriptBoundNames(pattern, source) { + id := typeScriptBindingID(node, bound, source) + if counts[id] == 0 { + declarations = append(declarations, declaration{id: id, value: nil}) + counts[id]++ + } + } + return + } + // A variable declarator or a class field with a literal value is a + // constant. A require binds an import, which the imports hold. + if len(typeScriptRequireBindings(node, source)) != 0 { + return + } + // A destructuring, an uninitialized let, and a for-of target bind + // their names without a literal, so they hide an outer constant of + // the same name as a parameter does. + hide := func(pattern *sitter.Node, id func(string) string) { + for _, bound := range typeScriptBoundNames(pattern, source) { + if bound := id(bound); counts[bound] == 0 { + declarations = append(declarations, declaration{id: bound, value: nil}) + counts[bound]++ + } + } + } + if node.Kind() == "for_in_statement" { + if left := node.ChildByFieldName("left"); left != nil { + hide(left, func(bound string) string { + return typeScriptBlockID(typeScriptQualifiedID(node, bound, source), node) + }) + } + return + } + name, valueNode := typeScriptDeclaration(node) + if name == nil { + return + } + if node.Kind() == "variable_declarator" && (valueNode == nil || name.Kind() != "identifier") { + hide(name, func(bound string) string { return typeScriptBindingID(node, bound, source) }) + return + } + if valueNode == nil || name.Kind() != "identifier" && name.Kind() != "property_identifier" { + return + } + id := typeScriptBindingID(node, code.Text(name, source), source) + declarations = append(declarations, declaration{id: id, value: valueNode}) + counts[id]++ + }) + declared := func(id string) bool { return counts[id] != 0 } + // A namespace import holds the exports of a module, so a write through + // it names one export. + namespace := func(name string) bool { + value := imported[name] + return value != nil && value.Fields != nil && value.Value == nil && counts[name] == 0 + } + mutated := map[string]bool{} + code.Walk(root, func(node *sitter.Node) { + switch node.Kind() { + case "assignment_expression", "augmented_assignment_expression", "update_expression": + left := node.ChildByFieldName("left") + if node.Kind() == "update_expression" { + // `count++` and `--this.limit` change their target. + left = node.ChildByFieldName("argument") + } + if field := typeScriptThisField(left, source); field != "" { + // `this.items = ...` changes the class field. + mutated[strings.Join(append(typeScriptMemberScopes(node, source), field), ".")] = true + } else if rootName := typeScriptMutationRoot(left, source); rootName != "" { + _, field := typeScriptChainParts(code.Text(typeScriptUnwrap(left), source)) + mutated[typeScriptMutatedName(node, rootName, field, source, declared, namespace)] = true + } + case "call_expression": + owner, method, ok := code.SplitMethod(typeScriptCallable(node, source)) + if !ok || method != "push" && method != "splice" && method != "pop" && + method != "shift" && method != "unshift" && method != "sort" && method != "reverse" { + return + } + // The call mutates the root of the receiver chain, LIST in + // `LIST[0].push(...)` and CONFIG in `CONFIG["items"].push(...)`. + rootName, field := typeScriptChainParts(owner) + if rootName == "this" && field != "" { + // `this.items.push(...)` changes the class field. + mutated[strings.Join(append(typeScriptMemberScopes(node, source), field), ".")] = true + } else if rootName != "" { + mutated[typeScriptMutatedName(node, rootName, field, source, declared, namespace)] = true + } + } + }) + + // Each literal is read in source order with the imports and every earlier + // constant that is declared once and not mutated in scope, so a chain of + // aliases resolves. A declaration that is not a literal keeps a nil + // entry, so that it still shadows an import or a module-level constant + // of the same name. + environment := maps.Clone(imported) + if environment == nil { + environment = map[string]*adapter.Node{} + } + values := map[string]*adapter.Node{} + // A named import and a field of a namespace import of one export are one + // value, so a mutation through either name freezes both. + typeScriptSpreadImportMutations(mutated, imported) + // An import that this file mutates is read-only here, as a local + // constant is, and the read-only copy shadows the import. A namespace + // import freezes only the exports that the file mutates. + for name, value := range imported { + if value == nil { + continue + } + if mutated[name] { + frozen := value.Clone() + frozen.MarkReadOnly("mutated constants are read-only") + environment[name], values[name] = frozen, frozen + continue + } + var frozen *adapter.Node + for field := range value.Fields { + if !mutated[name+"."+field] { + continue + } + if frozen == nil { + frozen = value.Clone() + } + frozen.Fields[field].MarkReadOnly("mutated constants are read-only") + } + if frozen != nil { + environment[name], values[name] = frozen, frozen + } + } + var resources []*adapter.Node + fallbacks := code.NewFallbackIDs() + for _, item := range declarations { + if item.value == nil { + // A parameter has no value and hides an outer name. + values[item.id], environment[item.id] = nil, nil + continue + } + // A module-level declaration of an imported name shadows the import + // from here on. The import stays visible to the uses before it. + if !strings.Contains(item.id, ".") && !typeScriptBlockScoped(item.id) { + code.RecordShadowedImport(values, item.id, imported[item.id], int(item.value.StartByte())) + } + value := typeScriptValue(item.value, source, environment) + if !code.IsLiteral(value) { + // The nil entries shadow an import of the same name here and in + // every later literal. + values[item.id], environment[item.id] = nil, nil + continue + } + if counts[item.id] == 1 && !mutated[item.id] { + environment[item.id] = value + } + if item.value.Kind() == "identifier" && counts[item.id] == 1 { + // `const ALSO = ITEMS` binds the object of ITEMS, so an import + // of either name binds one value. + origin := typeScriptScopedName(item.value, code.Text(item.value, source), source, declared) + if origin != item.id && declared(origin) { + code.RecordAlias(values, item.id, origin) + } + } + values[item.id] = value + code.RecordDeclaration(values, item.id, int(item.value.EndByte())) + resource := code.Constant(item.id, value) + // A binding of a nested block resolves the uses in its block, and + // is not a constant of the file. + if !typeScriptBlockScoped(item.id) { + resources = append(resources, resource) + } + // A name that is assigned more than once has no one value, so each + // of its assignments is a read-only constant with an id of its own. + if counts[item.id] != 1 { + values[item.id] = nil + resource.MarkReadOnly("reassigned constants are read-only") + } + if mutated[item.id] { + values[item.id] = nil + resource.MarkReadOnly("mutated constants are read-only") + } + if counts[item.id] != 1 || mutated[item.id] { + // An import of the name binds its first literal as read-only. + frozen := value.Clone() + frozen.MarkReadOnly(resource.Reason) + code.RecordFrozen(values, item.id, frozen) + } + // The positional id comes last, so that no read-only mark replaces it. + if counts[item.id] != 1 { + fallbacks.Distinguish(resource, item.id) + } + } + return values, resources +} + +// typeScriptThisField returns the class field that a property chain on this +// starts with, such as items in `this.items[0]`, or an empty string. +func typeScriptThisField(node *sitter.Node, source []byte) string { + field := "" + for node = typeScriptUnwrap(node); node != nil; node = typeScriptUnwrap(node) { + switch node.Kind() { + case "member_expression": + field = code.Text(node.ChildByFieldName("property"), source) + node = node.ChildByFieldName("object") + case "subscript_expression": + field = "" + node = node.ChildByFieldName("object") + case "this": + return field + default: + return "" + } + } + return "" +} + +// typeScriptExportedNames maps each exported name of a module to the local +// name that it exports: `export const X` exports X as X, and +// `export { A, B as C }` exports A as A and B as C. +func typeScriptExportedNames(root *sitter.Node, source []byte) map[string]string { + exported := map[string]string{} + code.Walk(root, func(node *sitter.Node) { + // `module.exports = { TEXT }` and `exports.TEXT = ...` are the + // CommonJS exports. + for _, export := range typeScriptModuleExports(node, source) { + exported[export.name] = export.local + } + // `export type { TEXT }` exports a type that TypeScript erases, so + // the value is not importable. `export { TEXT } from "./other"` + // exports a value of another module, not a constant of this file. + if node.Kind() != "export_statement" || typeScriptHasTypeKeyword(node, source) || typeScriptReExport(node) { + return + } + if declaration := node.ChildByFieldName("declaration"); declaration != nil { + for _, declarator := range code.NamedChildren(declaration) { + if declarator.Kind() != "variable_declarator" { + continue + } + if name := declarator.ChildByFieldName("name"); name != nil { + for _, bound := range typeScriptBoundNames(name, source) { + exported[bound] = bound + } + } + } + } + for _, clause := range code.NamedChildren(node) { + if clause.Kind() != "export_clause" { + continue + } + for _, specifier := range code.NamedChildren(clause) { + name := specifier.ChildByFieldName("name") + if specifier.Kind() != "export_specifier" || name == nil || typeScriptHasTypeKeyword(specifier, source) { + continue + } + local := code.Text(name, source) + as := local + if alias := specifier.ChildByFieldName("alias"); alias != nil { + as = code.Text(alias, source) + } + exported[as] = local + } + } + }) + return exported +} + +// typeScriptModuleExport is one CommonJS export: the exported name, the +// local name that holds its value, and the literal value when the export +// has no local name, as in `exports.TEXT = "Hello"`. +type typeScriptModuleExport struct { + name, local string + value *sitter.Node +} + +// typeScriptModuleExports returns the CommonJS exports of a node: each entry +// of `module.exports = { TEXT, OTHER: LOCAL, INLINE: "text" }`, the one +// export of `exports.TEXT = value` and `module.exports.TEXT = value`, or the +// whole module of `module.exports = value`. The TypeScript form `export = +// value` exports as `module.exports = value` does. Another node exports +// nothing. +func typeScriptModuleExports(node *sitter.Node, source []byte) []typeScriptModuleExport { + var target string + var right *sitter.Node + switch node.Kind() { + case "assignment_expression": + left := typeScriptUnwrap(node.ChildByFieldName("left")) + if left == nil { + return nil + } + target, right = code.Text(left, source), typeScriptUnwrap(node.ChildByFieldName("right")) + case "export_statement": + target, right = "module.exports", typeScriptExportEquals(node) + } + if right == nil { + return nil + } + export := func(name string, value *sitter.Node) typeScriptModuleExport { + if value != nil && value.Kind() == "identifier" { + return typeScriptModuleExport{name: name, local: code.Text(value, source)} + } + return typeScriptModuleExport{name: name, local: name, value: value} + } + switch { + case target == "module.exports" && right.Kind() == "object": + // The object is the module, so a default import binds the exports + // as one object. An entry named default replaces this marker. + exports := []typeScriptModuleExport{{name: "default", local: "*"}} + for _, entry := range code.NamedChildren(right) { + switch entry.Kind() { + case "shorthand_property_identifier": + name := code.Text(entry, source) + exports = append(exports, typeScriptModuleExport{name: name, local: name}) + case "pair": + if key, ok := typeScriptPropertyName(entry.ChildByFieldName("key"), source); ok { + exports = append(exports, export(key, typeScriptUnwrap(entry.ChildByFieldName("value")))) + } + } + } + return exports + case target == "module.exports": + // `module.exports = TEXT` or `module.exports = "Help"` makes one + // value the module, which a require binds whole and a default + // import binds as the default. A literal declares the default. + whole := export("default", right) + whole.name = "*" + return []typeScriptModuleExport{whole} + case strings.HasPrefix(target, "exports.") || strings.HasPrefix(target, "module.exports."): + name := target[strings.LastIndex(target, ".")+1:] + if strings.Contains(name, "[") || name == "" { + return nil + } + return []typeScriptModuleExport{export(name, right)} + } + return nil +} + +// typeScriptExportEquals returns the value of `export = value`, the +// TypeScript form of `module.exports = value`, or nil for another statement. +func typeScriptExportEquals(node *sitter.Node) *sitter.Node { + if node.Kind() != "export_statement" || node.ChildCount() < 3 || node.Child(1).Kind() != "=" { + return nil + } + return typeScriptUnwrap(node.NamedChild(0)) +} + +// typeScriptReExport reports whether an export statement exports the values +// of another module, as `export { TEXT } from "./other"` does. +func typeScriptReExport(node *sitter.Node) bool { + return node.ChildByFieldName("source") != nil +} + +// typeScriptDefaultExport returns the value that an export statement exports +// as the default, or nil: the expression of `export default value`, or the +// name in `export { TEXT as default }`. +func typeScriptDefaultExport(node *sitter.Node, source []byte) *sitter.Node { + if typeScriptReExport(node) { + // `export { default } from "./other"` is a value of another module. + return nil + } + for index := uint(0); index < node.ChildCount(); index++ { + child := node.Child(index) + if !child.IsNamed() && code.Text(child, source) == "default" { + return node.ChildByFieldName("value") + } + } + for _, clause := range code.NamedChildren(node) { + if clause.Kind() != "export_clause" { + continue + } + for _, specifier := range code.NamedChildren(clause) { + alias := specifier.ChildByFieldName("alias") + if specifier.Kind() == "export_specifier" && alias != nil && code.Text(alias, source) == "default" { + return specifier.ChildByFieldName("name") + } + } + } + return nil +} + +// typeScriptMutationRoot returns the name at the root of an assignment +// target, such as CONFIG in `CONFIG!.prompt` or `(CONFIG as T).list[0]`. +// A wrapper on a segment changes no runtime value, so it is left out. +func typeScriptMutationRoot(node *sitter.Node, source []byte) string { + for node = typeScriptUnwrap(node); node != nil; node = typeScriptUnwrap(node) { + switch node.Kind() { + case "member_expression", "subscript_expression": + node = node.ChildByFieldName("object") + case "identifier": + return code.Text(node, source) + default: + return "" + } + } + return "" +} + +// typeScriptChainParts splits a receiver chain into its root and the first +// segment after it: LIST and "" for `LIST[0]`, CONFIG and items for +// `CONFIG.items[0]`, and Bot and items for `Bot.items.push`. +func typeScriptChainParts(chain string) (root, field string) { + index := strings.IndexAny(chain, ".[") + if index < 0 { + return chain, "" + } + root, rest := chain[:index], chain[index:] + if !strings.HasPrefix(rest, ".") { + return root, "" + } + rest = rest[1:] + if end := strings.IndexAny(rest, ".[("); end >= 0 { + rest = rest[:end] + } + return root, rest +} + +// typeScriptMutatedName returns the id that a write through a receiver chain +// mutates: the static field of a class when the chain starts with the class +// name, as in `Bot.items.push(...)`, one export of a namespace import, as in +// `values.LIST.push(...)`, else the constant that the root names. +func typeScriptMutatedName( + node *sitter.Node, + rootName, field string, + source []byte, + declared, namespace func(string) bool, +) string { + if field != "" { + if candidate := typeScriptScopedName(node, rootName+".static."+field, source, declared); declared(candidate) { + return candidate + } + if namespace(rootName) && typeScriptScopedName(node, rootName, source, declared) == rootName { + return rootName + "." + field + } + } + return typeScriptScopedName(node, rootName, source, declared) +} + +// typeScriptConstant returns a copy of the constant that a use of name at a +// byte offset refers to, or nil when none is in scope. The copy keeps a later +// change to one use, such as a read-only mark, away from the other uses. +func typeScriptConstant(node *sitter.Node, name string, use int, source []byte, constants map[string]*adapter.Node) *adapter.Node { + if constants == nil { + return nil + } + declared := func(id string) bool { _, ok := constants[id]; return ok } + scoped := typeScriptScopedName(node, name, source, declared) + value := constants[scoped] + if code.DeclaredAt(constants, scoped, value, use) || value != nil && typeScriptDeferredUse(node, scoped, source) { + return value.Clone() + } + if scoped == name { + return code.ShadowedImport(constants, name, use) + } + return nil +} + +// typeScriptDeferredUse reports whether a use runs after the scope of the +// constant finished: a function body between the scope of the constant and +// the use runs later, so it sees a constant that the file declares after it. +func typeScriptDeferredUse(node *sitter.Node, scoped string, source []byte) bool { + // An instance field initializer runs in the constructor, after the + // module loads. A static field runs with the class declaration. + for parent := node.Parent(); parent != nil && parent.Kind() != "class_body"; parent = parent.Parent() { + if (parent.Kind() == "public_field_definition" || parent.Kind() == "field_definition") && + !typeScriptStaticField(parent, source) { + return true + } + } + chain := typeScriptScopeChain(node, source) + for _, scope := range chain[min(strings.Count(scoped, "."), len(chain)):] { + if !scope.class { + return true + } + } + return false +} + +// typeScriptSpreadImportMutations marks every import name that shares a value +// with a mutated one: `LIST` of `import { LIST }` and `values.LIST` of +// `import * as values` bind one export, so a write through one of them +// mutates the other. +func typeScriptSpreadImportMutations(mutated map[string]bool, imported map[string]*adapter.Node) { + shared := map[*adapter.Node][]string{} + for name, value := range imported { + if value == nil || strings.Contains(name, " ") { + continue + } + shared[value] = append(shared[value], name) + for field, entry := range value.Fields { + if entry != nil { + shared[entry] = append(shared[entry], name+"."+field) + } + } + } + for _, names := range shared { + if !slices.ContainsFunc(names, func(name string) bool { return mutated[name] }) { + continue + } + for _, name := range names { + mutated[name] = true + } + } +} + +// typeScriptStaticScope reports whether a scope node is a static member of +// its class: a static method, or a function or class that initializes a +// static field. +func typeScriptStaticScope(node *sitter.Node, source []byte) bool { + switch node.Kind() { + case "method_definition": + return typeScriptStaticField(node, source) + case "arrow_function", "function_expression", "function", "generator_function", "class": + parent := node.Parent() + if parent == nil || parent.Kind() != "public_field_definition" && parent.Kind() != "field_definition" { + return false + } + return typeScriptStaticField(parent, source) + } + return false +} + +// typeScriptStaticField reports whether a class field has the static +// modifier. +func typeScriptStaticField(field *sitter.Node, source []byte) bool { + for index := uint(0); index < field.ChildCount(); index++ { + child := field.Child(index) + if !child.IsNamed() && code.Text(child, source) == "static" { + return true + } + } + return false +} + +// typeScriptScopedName returns the id that a use of name refers to: the +// constant of the nearest enclosing function or class that declares it, else +// the module-level name. declared reports whether an id has a constant. A +// bare name inside a method does not see the fields of the class, because +// JavaScript resolves it through the enclosing functions and then the +// module. A property chain, such as this.client, belongs to the class. +func typeScriptScopedName(node *sitter.Node, name string, source []byte, declared func(string) bool) string { + scopes := typeScriptScopeChain(node, source) + bare := !strings.Contains(name, ".") + // A let or const of a nested block binds its name in that block, so the + // nearest block around the use that declares the name wins. + if bare { + for parent := node.Parent(); parent != nil; parent = parent.Parent() { + if !typeScriptNestedBlock(parent) { + continue + } + if candidate := typeScriptBlockID(typeScriptQualifiedID(parent, name, source), parent); declared(candidate) { + return candidate + } + } + } + for end := len(scopes); end > 0; end-- { + if bare && end < len(scopes) && scopes[end-1].class { + continue + } + names := make([]string, 0, end+1) + for _, scope := range scopes[:end] { + names = append(names, scope.name) + } + if candidate := strings.Join(append(names, name), "."); declared(candidate) { + return candidate + } + } + return name +} + +// typeScriptScope is one class or function that encloses a node. +type typeScriptScope struct { + name string + class bool +} + +// typeScriptScopeChain returns the classes and functions that enclose a +// node, outermost first. +func typeScriptScopeChain(node *sitter.Node, source []byte) []typeScriptScope { + var scopes []typeScriptScope + for parent := node.Parent(); parent != nil; parent = parent.Parent() { + name := typeScriptScopeName(parent, source) + if name == "" { + continue + } + class := false + switch parent.Kind() { + case "class_declaration", "abstract_class_declaration", "class": + class = true + } + entry := []typeScriptScope{{name: name, class: class}} + if typeScriptStaticScope(parent, source) { + // A static method or a static field initializer belongs to the + // class, not to its instances, so its scope carries the static + // marker, as a static field does, and an instance member of the + // same name is another scope. The marker counts as a class + // scope, so a bare name does not reach the static fields + // through it. + entry = append([]typeScriptScope{{name: "static", class: true}}, entry...) + } + scopes = append(entry, scopes...) + } + return scopes +} + +// typeScriptScopes returns the names of the classes and functions that +// enclose a node, outermost first. +func typeScriptScopes(node *sitter.Node, source []byte) []string { + chain := typeScriptScopeChain(node, source) + scopes := make([]string, len(chain)) + for index, scope := range chain { + scopes[index] = scope.name + } + return scopes +} + +// typeScriptUnwrap returns the expression inside type wrappers and +// parentheses, such as `{...} as const`, `value satisfies T`, `value!`, +// `value`, and `(value)`. The wrappers change no runtime value. +func typeScriptUnwrap(node *sitter.Node) *sitter.Node { + for node != nil { + switch node.Kind() { + case "as_expression", "satisfies_expression", "non_null_expression", "parenthesized_expression", "await_expression": + node = node.NamedChild(0) + case "type_assertion": + node = node.NamedChild(node.NamedChildCount() - 1) + default: + return node + } + } + return nil +} + +// typeScriptCallable returns the name of the function or constructor of a +// call, such as client.chat.completions.create. A type wrapper or +// parentheses on a segment, as in `client!.chat` or `(server as McpServer)`, +// change no runtime value, so the name leaves them out. +func typeScriptCallable(call *sitter.Node, source []byte) string { + function := call.ChildByFieldName("function") + if function == nil { + function = call.ChildByFieldName("constructor") + } + return typeScriptCallableName(function, source) +} + +// typeScriptCallableName returns the dotted name of a callee expression +// without the wrappers on its segments. +func typeScriptCallableName(node *sitter.Node, source []byte) string { + node = typeScriptUnwrap(node) + if node == nil { + return "" + } + if node.Kind() == "member_expression" { + property := code.Text(node.ChildByFieldName("property"), source) + if object := typeScriptCallableName(node.ChildByFieldName("object"), source); object != "" { + return object + "." + property + } + return property + } + return code.Text(node, source) +} + +// typeScriptQualifiedID prefixes a name with every enclosing class and +// function, outermost first, so that two methods with one name in two classes +// keep their locals apart. +func typeScriptQualifiedID(node *sitter.Node, name string, source []byte) string { + return strings.Join(append(typeScriptScopes(node, source), name), ".") +} + +// typeScriptScopeName returns the name of a function or class scope, or an +// empty string for a node that opens no named scope. A function expression +// or an arrow function takes the name of the variable, property, or class +// field that holds it. A callback that is only an argument has no name. +func typeScriptScopeName(node *sitter.Node, source []byte) string { + name := "" + switch node.Kind() { + case "function_declaration", "generator_function_declaration", "method_definition", + "class_declaration", "abstract_class_declaration": + name = code.Text(node.ChildByFieldName("name"), source) + case "arrow_function", "function_expression", "function", "generator_function", "class": + parent := node.Parent() + if parent == nil { + break + } + switch parent.Kind() { + case "variable_declarator", "public_field_definition": + name = code.Text(parent.ChildByFieldName("name"), source) + case "field_definition": + name = code.Text(parent.ChildByFieldName("property"), source) + case "pair": + name, _ = typeScriptPropertyName(parent.ChildByFieldName("key"), source) + case "assignment_expression": + name = code.Text(parent.ChildByFieldName("left"), source) + } + } + if name == "" && typeScriptClassKind(node.Kind()) { + // An anonymous class, as `export default class {}` or the class of + // a mixin factory, is a scope of its own, so its members and its + // methods share one name, taken from its position. + return fmt.Sprintf("class@%d", node.StartByte()) + } + return name +} + +// typeScriptClassKind reports whether a node kind is a class. +func typeScriptClassKind(kind string) bool { + return kind == "class_declaration" || kind == "abstract_class_declaration" || kind == "class" +} + +// typeScriptPromptCalls returns the prompt calls that a declaration value +// holds. A call that a declaration or an assignment inside the value holds, +// such as a constant in an arrow function body, belongs to that inner +// binding, which names it on its own. +func typeScriptPromptCalls(node *sitter.Node, imports typeScriptAliases, source []byte) []*sitter.Node { + var calls []*sitter.Node + code.Walk(node, func(candidate *sitter.Node) { + if typeScriptPromptCall(candidate, imports, source) && !typeScriptInnerBinding(candidate, node) { + calls = append(calls, candidate) + } + }) + return calls +} + +// typeScriptInnerBinding reports whether a declaration between a call and +// the value that holds it names the call itself. The value itself, or a call +// that only wrappers separate from it, has no inner declaration. +func typeScriptInnerBinding(call, value *sitter.Node) bool { + if code.SameNode(call, value) { + return false + } + for parent := call.Parent(); parent != nil && !code.SameNode(parent, value); parent = parent.Parent() { + if name, _ := typeScriptDeclaration(parent); name != nil { + return true + } + // A function or a class nested in the value is a scope of its own, + // so a prompt in it belongs to that scope and not to the variable. + if typeScriptFunctionKind(parent.Kind()) || parent.Kind() == "class" || parent.Kind() == "class_declaration" { + return true + } + } + return false +} + +// typeScriptPromptCall reports whether a node builds a prompt template of an +// allowlisted framework. +func typeScriptPromptCall(node *sitter.Node, imports typeScriptAliases, source []byte) bool { + if node.Kind() != "call_expression" && node.Kind() != "new_expression" { + return false + } + callable := typeScriptCallable(node, source) + owner, method, qualified := code.SplitMethod(callable) + kinds := imports.at(node, source) + return kinds[callable] == "prompt" || + (qualified && kinds[owner] == "prompt" && (method == "fromMessages" || method == "fromTemplate")) +} + +func typeScriptPromptFields( + call *sitter.Node, + imports typeScriptAliases, + source []byte, + constants map[string]*adapter.Node, +) map[string]*adapter.Node { + callable := typeScriptCallable(call, source) + owner, method, qualified := code.SplitMethod(callable) + arguments := code.NamedChildren(call.ChildByFieldName("arguments")) + if qualified && imports.at(call, source)[owner] == "prompt" && len(arguments) != 0 { + if method == "fromMessages" { + return code.TranslateFields( + map[string]*adapter.Node{"messages": typeScriptMessages(arguments[0], source, constants)}, + code.BracePlaceholders, "messages", + ) + } + if method == "fromTemplate" { + return code.TranslateFields( + map[string]*adapter.Node{"template": typeScriptValue(arguments[0], source, constants)}, + code.BracePlaceholders, "template", + ) + } + } + if len(arguments) == 0 { + return map[string]*adapter.Node{} + } + first := typeScriptUnwrap(arguments[0]) + if first.Kind() == "array" || code.MessageList(typeScriptValue(first, source, constants)) != nil { + return code.TranslateFields( + map[string]*adapter.Node{"messages": typeScriptMessages(first, source, constants)}, + code.BracePlaceholders, "messages", + ) + } + return code.TranslateFields( + typeScriptObject(arguments[0], source, constants).Fields, + code.BracePlaceholders, "template", "messages", + ) +} + +// typeScriptImports maps each imported name of an allowlisted framework to +// the kind of symbol that it is, such as "prompt" or "mcp-server". +// typeScriptAliases are the framework symbols that the imports of a file +// bind, such as Agent to "openai-agent", with the scope of each binding and +// the local names that hide a binding. An import or a module-level require +// is visible in the whole file, a require inside a function in that +// function, and a local declaration of the same name hides them both. +type typeScriptAliases struct { + bindings []typeScriptAliasBinding + shadows []typeScriptShadow +} + +// typeScriptAliasBinding is one framework symbol that an import binds. +type typeScriptAliasBinding struct { + alias, kind string + // scopes are the functions and classes that enclose the binding. + scopes []string + end int + // block is the byte range of the block that declares the binding: the + // program for an import, or the block that holds a require. + block [2]int +} + +// typeScriptShadow is a local declaration whose name hides an alias in the +// block that holds it: the function body, or a nested block such as the +// body of a loop or a condition. +type typeScriptShadow struct { + name string + start, end int +} + +// hiddenBy reports whether a shadow hides the binding at a use inside the +// shadow. The nearer declaration wins: a binding of the block of the shadow, +// such as the require that the shadow records, or of a block inside it, +// stays. A binding of an outer block, or of a block that does not hold the +// use, is hidden. +func (binding typeScriptAliasBinding) hiddenBy(shadow typeScriptShadow, use int) bool { + if use < binding.block[0] || use >= binding.block[1] { + return true + } + return shadow.start > binding.block[0] || shadow.end < binding.block[1] +} + +// typeScriptImports returns the framework symbols that the imports and the +// requires of a file bind. +func typeScriptImports(root *sitter.Node, source []byte) typeScriptAliases { + return typeScriptImportsFrom(root, source, false) +} + +// typeScriptTypeImports returns the framework symbols that the imports bind +// as values or as types. A type annotation names a client kind through a +// type-only import too, as `import type OpenAI from "openai"` does, while +// the value imports leave such a binding out. +func typeScriptTypeImports(root *sitter.Node, source []byte) typeScriptAliases { + return typeScriptImportsFrom(root, source, true) +} + +func typeScriptImportsFrom(root *sitter.Node, source []byte, includeTypes bool) typeScriptAliases { + var aliases typeScriptAliases + add := func(node *sitter.Node, alias, kind string) { + // A var require binds its names in the whole function. + block := typeScriptBindingBlock(node) + aliases.bindings = append(aliases.bindings, typeScriptAliasBinding{ + alias: alias, kind: kind, scopes: typeScriptScopes(node, source), end: int(node.EndByte()), + block: [2]int{int(block.StartByte()), int(block.EndByte())}, + }) + } + for _, site := range typeScriptImportSitesFrom(root, source, includeTypes) { + allowed := typeScriptAllowedImports(site.binding.module) + if allowed == nil { + continue + } + if site.binding.name == "*" { + // `import * as agents` makes every symbol reachable as agents.Agent. + for _, name := range slices.Sorted(maps.Keys(allowed)) { + if name != "default" { + add(site.node, site.binding.alias+"."+name, allowed[name]) + } + } + continue + } + if kind := allowed[site.binding.name]; kind != "" { + add(site.node, site.binding.alias, kind) + } + } + code.Walk(root, func(node *sitter.Node) { + // A class field is a property of the instance, not a name of the + // class body, so it hides no import in the methods. + if node.Kind() == "public_field_definition" || node.Kind() == "field_definition" { + return + } + name, _ := typeScriptDeclaration(node) + if name == nil { + // A parameter binds its name in the whole function, and a local + // function, class, or catch parameter binds it in its block. + name = typeScriptLocalBinding(node) + } + if name == nil { + return + } + block := typeScriptBindingBlock(node) + if block.Kind() == "program" { + // A module-level declaration is a constant, not a shadow. A + // declaration in a block at module level is a shadow of the + // block. + return + } + for _, bound := range typeScriptBoundNames(name, source) { + aliases.shadows = append(aliases.shadows, typeScriptShadow{ + name: bound, start: int(block.StartByte()), end: int(block.EndByte()), + }) + } + }) + return aliases +} + +// typeScriptBindingBlock returns the block in which a node binds its names: +// the catch clause for its parameter, the function or the program for a +// var, else the block around the node. +func typeScriptBindingBlock(node *sitter.Node) *sitter.Node { + switch { + case node.Kind() == "catch_clause": + return node + case typeScriptVarDeclarator(node): + // JavaScript hoists a var to its function, so the var binds its + // name in the whole function, even from a branch that never runs. + for parent := node.Parent(); parent != nil; parent = parent.Parent() { + if typeScriptFunctionKind(parent.Kind()) || parent.Kind() == "program" { + return parent + } + } + } + return typeScriptBlock(node) +} + +// typeScriptVarDeclarator reports whether a node is the declarator of a var. +func typeScriptVarDeclarator(node *sitter.Node) bool { + parent := node.Parent() + return node.Kind() == "variable_declarator" && parent != nil && parent.Kind() == "variable_declaration" +} + +// typeScriptFunctionKind reports whether a node kind is a function, whose +// body is the scope of its parameters and its var declarations. +func typeScriptFunctionKind(kind string) bool { + switch kind { + case "arrow_function", "function_expression", "function_declaration", "method_definition", + "function", "generator_function", "generator_function_declaration": + return true + } + return false +} + +// typeScriptNestedBlock reports whether a block is nested in its function or +// in the module: a loop, a switch body, a catch clause, or a statement block +// that is not the body of a function. A let or const binds its name in such +// a block only. +func typeScriptNestedBlock(block *sitter.Node) bool { + switch block.Kind() { + case "for_statement", "for_in_statement", "switch_body", "catch_clause": + return true + case "statement_block": + parent := block.Parent() + return parent != nil && !typeScriptFunctionKind(parent.Kind()) + } + return false +} + +// typeScriptBindingID returns the id of a declaration: its qualified name, +// with the start of its block when a let, a const, a catch parameter, or a +// local function or class binds the name in a nested block only. A var binds +// its name in the whole function. +func typeScriptBindingID(node *sitter.Node, name string, source []byte) string { + if (node.Kind() == "public_field_definition" || node.Kind() == "field_definition") && typeScriptStaticField(node, source) { + // A static field belongs to the class, not to its instances. + return strings.Join(append(typeScriptScopes(node, source), "static", name), ".") + } + id := typeScriptQualifiedID(node, name, source) + if block := typeScriptBindingBlock(node); typeScriptNestedBlock(block) { + return typeScriptBlockID(id, block) + } + return id +} + +// typeScriptBlockID returns the id of a name in a nested block. +func typeScriptBlockID(id string, block *sitter.Node) string { + return fmt.Sprintf("%s@%d", id, block.StartByte()) +} + +// typeScriptBlockScoped reports whether an id names a binding of a nested +// block, which is not a constant of the file. +func typeScriptBlockScoped(id string) bool { + return strings.Contains(id, "@") +} + +// typeScriptLocalBinding returns the pattern that a node binds in its +// block besides a variable: a parameter, a catch parameter, or the name of a +// function or class declaration. It returns nil for another node. +func typeScriptLocalBinding(node *sitter.Node) *sitter.Node { + switch node.Kind() { + case "catch_clause": + return node.ChildByFieldName("parameter") + case "function_declaration", "generator_function_declaration", "class_declaration", "abstract_class_declaration": + return node.ChildByFieldName("name") + } + return typeScriptParameterPattern(node) +} + +// typeScriptParameterPattern returns the pattern that a function parameter +// binds, or nil for another node. +func typeScriptParameterPattern(node *sitter.Node) *sitter.Node { + parent := node.Parent() + if parent == nil { + return nil + } + // `x => x` has its one parameter without parentheses. + if parent.Kind() == "arrow_function" && node.Kind() == "identifier" && + code.SameNode(parent.ChildByFieldName("parameter"), node) { + return node + } + if parent.Kind() != "formal_parameters" { + return nil + } + switch node.Kind() { + case "required_parameter", "optional_parameter", "rest_parameter", "rest_pattern": + // TypeScript wraps a parameter; a JavaScript rest parameter is a + // rest_pattern around its name. + if pattern := node.ChildByFieldName("pattern"); pattern != nil { + return pattern + } + return node.NamedChild(0) + case "identifier", "object_pattern", "array_pattern", "assignment_pattern": + if node.Kind() == "assignment_pattern" { + return node.ChildByFieldName("left") + } + return node + } + return nil +} + +// typeScriptBlock returns the block that a declaration binds its names in: +// the nearest statement block, loop, class body, or the program. +func typeScriptBlock(node *sitter.Node) *sitter.Node { + for parent := node.Parent(); parent != nil; parent = parent.Parent() { + switch parent.Kind() { + case "statement_block", "for_statement", "for_in_statement", "switch_body", "class_body", "program", + "catch_clause", "arrow_function", "function_expression", "function_declaration", "method_definition", + "function", "generator_function", "generator_function_declaration": + return parent + } + } + return node +} + +// typeScriptBoundNames returns the names that a declaration pattern binds: +// the identifier, or every identifier of an object or array pattern. +func typeScriptBoundNames(pattern *sitter.Node, source []byte) []string { + switch pattern.Kind() { + case "identifier", "property_identifier", "shorthand_property_identifier_pattern", "type_identifier": + // A class declaration names its class with a type identifier. + return []string{code.Text(pattern, source)} + case "pair_pattern": + if value := pattern.ChildByFieldName("value"); value != nil { + return typeScriptBoundNames(value, source) + } + return nil + case "assignment_pattern", "object_assignment_pattern": + // The default on the right is a value, not a binding. + if left := pattern.ChildByFieldName("left"); left != nil { + return typeScriptBoundNames(left, source) + } + return nil + } + var names []string + for _, child := range code.NamedChildren(pattern) { + names = append(names, typeScriptBoundNames(child, source)...) + } + return names +} + +// at returns the framework symbols that a use at node sees. A binding of a +// nearer scope wins over one of an outer scope, and a local declaration of +// a name hides the bindings of that name in its block. +func (aliases typeScriptAliases) at(node *sitter.Node, source []byte) map[string]string { + use := int(node.StartByte()) + scopes := typeScriptScopes(node, source) + encloses := func(outer []string) bool { + return len(scopes) >= len(outer) && slices.Equal(scopes[:len(outer)], outer) + } + visible := map[string]typeScriptAliasBinding{} + for _, binding := range aliases.bindings { + if len(binding.scopes) != 0 && (!encloses(binding.scopes) || binding.end > use) { + continue + } + // A require binds its names in its block only. + if use < binding.block[0] || use >= binding.block[1] { + continue + } + if seen, ok := visible[binding.alias]; ok && len(seen.scopes) > len(binding.scopes) { + continue + } + visible[binding.alias] = binding + } + for _, shadow := range aliases.shadows { + if use < shadow.start || use >= shadow.end { + continue + } + for alias, binding := range visible { + if root, _, _ := strings.Cut(alias, "."); root == shadow.name && binding.hiddenBy(shadow, use) { + delete(visible, alias) + } + } + } + kinds := make(map[string]string, len(visible)) + for alias, binding := range visible { + kinds[alias] = binding.kind + } + return kinds +} + +// typeScriptImportBinding is one name that an import statement creates. name +// is "default" for a default import and "*" for a namespace import. +type typeScriptImportBinding struct { + module string + name string + alias string +} + +// typeScriptImportBindings returns the bindings of the modules that are not +// allowlisted frameworks, so that imported constants can resolve. +func typeScriptImportBindings(root *sitter.Node, source []byte) []typeScriptImportBinding { + var bindings []typeScriptImportBinding + for _, binding := range typeScriptImportClauses(root, source) { + if typeScriptAllowedImports(binding.module) == nil { + bindings = append(bindings, binding) + } + } + return bindings +} + +// typeScriptImportClauses returns every binding of every import statement. +// One statement can bind a default import, a namespace, and named imports at +// once. A type-only import binds no value. +func typeScriptImportClauses(root *sitter.Node, source []byte) []typeScriptImportBinding { + var bindings []typeScriptImportBinding + for _, site := range typeScriptImportSites(root, source) { + // A require inside a function binds a local name, not a symbol of + // the file, so the repository modules of a file come from its + // module-level imports and requires. + if len(typeScriptScopes(site.node, source)) == 0 { + bindings = append(bindings, site.binding) + } + } + return bindings +} + +// typeScriptImportSite is one binding with the statement that creates it. +type typeScriptImportSite struct { + binding typeScriptImportBinding + node *sitter.Node +} + +// typeScriptImportSites returns every binding of every import statement and +// every require, with the node that creates it. +func typeScriptImportSites(root *sitter.Node, source []byte) []typeScriptImportSite { + return typeScriptImportSitesFrom(root, source, false) +} + +// typeScriptImportSitesFrom returns the import sites, with the type-only +// imports when includeTypes is true. +func typeScriptImportSitesFrom(root *sitter.Node, source []byte, includeTypes bool) []typeScriptImportSite { + var sites []typeScriptImportSite + add := func(node *sitter.Node, bindings ...typeScriptImportBinding) { + for _, binding := range bindings { + sites = append(sites, typeScriptImportSite{binding: binding, node: node}) + } + } + code.Walk(root, func(node *sitter.Node) { + if node.Kind() == "variable_declarator" { + add(node, typeScriptRequireBindings(node, source)...) + return + } + if node.Kind() != "import_statement" || !includeTypes && typeScriptHasTypeKeyword(node, source) { + return + } + for _, clause := range code.NamedChildren(node) { + // `import OpenAI = require("openai")` binds the module as a + // `const OpenAI = require("openai")` does. + if clause.Kind() == "import_require_clause" { + add(node, typeScriptImportRequire(clause, source)...) + return + } + } + module, ok := code.StringValue(typeScriptValue(node.ChildByFieldName("source"), source, nil)) + if !ok { + return + } + for _, clause := range code.NamedChildren(node) { + if clause.Kind() != "import_clause" { + continue + } + for _, child := range code.NamedChildren(clause) { + switch child.Kind() { + case "identifier": + add(node, typeScriptImportBinding{module: module, name: "default", alias: code.Text(child, source)}) + case "namespace_import": + if names := code.NamedChildren(child); len(names) == 1 { + add(node, typeScriptImportBinding{module: module, name: "*", alias: code.Text(names[0], source)}) + } + case "named_imports": + add(node, typeScriptNamedImports(child, module, source, includeTypes)...) + } + } + } + }) + return sites +} + +// typeScriptImportRequire reads the TypeScript CommonJS form +// `import OpenAI = require("openai")`. +func typeScriptImportRequire(clause *sitter.Node, source []byte) []typeScriptImportBinding { + var name, module *sitter.Node + for _, child := range code.NamedChildren(clause) { + switch child.Kind() { + case "identifier": + name = child + case "string": + module = child + } + } + if name == nil || module == nil { + return nil + } + text, ok := code.StringValue(typeScriptValue(module, source, nil)) + if !ok { + return nil + } + // The name holds the module, as a namespace import does, and a package + // that exports one class is that class, as a default import. The + // namespace binding comes last, so that a required repository module + // reads as its exports. + alias := code.Text(name, source) + return []typeScriptImportBinding{{module: text, name: "default", alias: alias}, {module: text, name: "*", alias: alias}} +} + +// typeScriptRequireBindings returns the bindings of a CommonJS require: +// `const OpenAI = require("openai")` binds the default export, and +// `const { Agent, tool as t } = require("@openai/agents")` binds named exports. +func typeScriptRequireBindings(declarator *sitter.Node, source []byte) []typeScriptImportBinding { + name := declarator.ChildByFieldName("name") + call := typeScriptUnwrap(declarator.ChildByFieldName("value")) + // `require("pkg").Agent` binds one export of the module. + member := "" + if call != nil && call.Kind() == "member_expression" { + if property := call.ChildByFieldName("property"); property != nil { + member = code.Text(property, source) + } + call = typeScriptUnwrap(call.ChildByFieldName("object")) + } + if name == nil || call == nil || call.Kind() != "call_expression" || typeScriptCallable(call, source) != "require" { + return nil + } + arguments := code.NamedChildren(call.ChildByFieldName("arguments")) + if len(arguments) != 1 { + return nil + } + module, ok := code.StringValue(typeScriptValue(arguments[0], source, nil)) + if !ok { + return nil + } + if member != "" { + if name.Kind() != "identifier" { + return nil + } + return []typeScriptImportBinding{{module: module, name: member, alias: code.Text(name, source)}} + } + switch name.Kind() { + case "identifier": + // `const agents = require("@openai/agents")` holds the module, so + // every export is reachable as agents.Agent, as a namespace import, + // and a package that exports one class is that class, as a default + // import. The namespace binding comes last, so that a required + // repository module reads as its exports. + alias := code.Text(name, source) + return []typeScriptImportBinding{{module: module, name: "default", alias: alias}, {module: module, name: "*", alias: alias}} + case "object_pattern": + var bindings []typeScriptImportBinding + for _, property := range code.NamedChildren(name) { + switch property.Kind() { + case "shorthand_property_identifier_pattern": + text := code.Text(property, source) + bindings = append(bindings, typeScriptImportBinding{module: module, name: text, alias: text}) + case "object_assignment_pattern": + // `{ Agent = Fallback }` binds Agent with a default. + if left := property.ChildByFieldName("left"); left != nil && left.Kind() == "shorthand_property_identifier_pattern" { + text := code.Text(left, source) + bindings = append(bindings, typeScriptImportBinding{module: module, name: text, alias: text}) + } + case "pair_pattern": + key, value := property.ChildByFieldName("key"), property.ChildByFieldName("value") + if value != nil && value.Kind() == "assignment_pattern" { + // `{ Tool: T = Fallback }` binds T with a default. + value = value.ChildByFieldName("left") + } + if key != nil && value != nil && value.Kind() == "identifier" { + bindings = append(bindings, typeScriptImportBinding{ + module: module, name: code.Text(key, source), alias: code.Text(value, source), + }) + } + } + } + return bindings + } + return nil +} + +func typeScriptNamedImports(node *sitter.Node, module string, source []byte, includeTypes bool) []typeScriptImportBinding { + var bindings []typeScriptImportBinding + for _, specifier := range code.NamedChildren(node) { + name := specifier.ChildByFieldName("name") + if specifier.Kind() != "import_specifier" || name == nil || !includeTypes && typeScriptHasTypeKeyword(specifier, source) { + continue + } + binding := typeScriptImportBinding{module: module, name: code.Text(name, source), alias: code.Text(name, source)} + if alias := specifier.ChildByFieldName("alias"); alias != nil { + binding.alias = code.Text(alias, source) + } + bindings = append(bindings, binding) + } + return bindings +} + +// typeScriptHasTypeKeyword reports whether an import statement or an import +// specifier has the type keyword, which imports only a type. +func typeScriptHasTypeKeyword(node *sitter.Node, source []byte) bool { + for index := uint(0); index < node.ChildCount(); index++ { + child := node.Child(index) + if !child.IsNamed() && code.Text(child, source) == "type" { + return true + } + } + return false +} + +func typeScriptImportedModules(root *sitter.Node, source []byte) map[string]struct{} { + modules := map[string]struct{}{} + for _, binding := range typeScriptImportBindings(root, source) { + if strings.HasPrefix(binding.module, ".") { + modules[binding.module] = struct{}{} + } + } + return modules +} + +// typeScriptSourceExtensions are the file extensions of a module, in the +// order that a resolver tries them. +var typeScriptSourceExtensions = []string{".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"} + +func typeScriptModulePaths(primary, module string) []string { + base := path.Clean(path.Join(path.Dir(primary), module)) + for _, extension := range typeScriptSourceExtensions { + base = strings.TrimSuffix(base, extension) + } + paths := make([]string, 0, 2*len(typeScriptSourceExtensions)) + for _, extension := range typeScriptSourceExtensions { + paths = append(paths, base+extension) + } + for _, extension := range typeScriptSourceExtensions { + paths = append(paths, path.Join(base, "index"+extension)) + } + return paths +} + +func typeScriptImportedValues(bundle adapter.SourceBundle, typed bool) map[string]*adapter.Node { + result := map[string]*adapter.Node{} + tree, err := code.ParseTree(bundle.Primary, typeScriptLanguage(bundle.Primary.Path, typed)) + if err != nil { + return result + } + defer tree.Close() + cache := map[string]map[string]*adapter.Node{} + for _, binding := range typeScriptImportBindings(tree.RootNode(), bundle.Primary.Data) { + if !strings.HasPrefix(binding.module, ".") { + result[binding.alias] = code.UnresolvedImport(binding.module, binding.name, binding.module) + continue + } + candidates := typeScriptModulePaths(bundle.Primary.Path, binding.module) + var document adapter.Document + for _, candidate := range candidates { + if value, ok := bundle.Dependencies[candidate]; ok { + document = value + break + } + } + if document.Path == "" { + result[binding.alias] = code.UnresolvedImport(binding.module, binding.name, candidates[0]) + continue + } + constants := cache[document.Path] + if constants == nil { + dependencyTree, parseErr := code.ParseTree(document, typeScriptLanguage(document.Path, typed)) + if parseErr != nil { + result[binding.alias] = code.UnresolvedImport(binding.module, binding.name, document.Path) + continue + } + constants, _ = typeScriptConstants(dependencyTree.RootNode(), document.Data, nil) + // A module exports only the names that it declares exported, so + // a private constant is not importable. + exported := typeScriptExportedNames(dependencyTree.RootNode(), document.Data) + dependencyTree.Close() + code.LocateConstants(constants, document) + visible := map[string]*adapter.Node{} + for name, local := range exported { + // An alias in the module binds the object of its origin. + local = code.AliasOrigin(constants, local) + constant, ok := constants[local] + if ok && constant == nil { + // A name that the module reassigns or mutates exports + // its first literal as read-only, so the import + // resolves but blocks a write. + constant = code.Frozen(constants, local) + } + if constant != nil { + visible[name] = constant + } + } + // `export default value` declares the default itself. An export + // clause maps the default to its constant above, which also + // covers a constant that the clause names before its declaration. + if constant := constants["default"]; constant != nil { + visible["default"] = constant + } + // `module.exports = { ... }` makes the object the module, so a + // default import binds the named exports as one object, as a + // require does. + if exported["default"] == "*" && visible["*"] == nil && visible["default"] == nil { + whole := &adapter.Node{Fields: maps.Clone(visible)} + visible["*"] = whole + } + // `module.exports = value` makes the value the module, so a + // default import binds it too. + if visible["default"] == nil && visible["*"] != nil { + visible["default"] = visible["*"] + } + constants = visible + cache[document.Path] = constants + } + var value *adapter.Node + if whole := constants["*"]; binding.name == "*" && whole != nil { + value = whole + } else if binding.name == "*" { + value = &adapter.Node{Fields: map[string]*adapter.Node{}} + for name, constant := range constants { + value.Fields[name] = constant + } + } else { + value = constants[binding.name] + } + if value == nil { + value = code.UnresolvedImport(binding.module, binding.name, document.Path) + } + result[binding.alias] = value + } + return result +} + +// typeScriptBinding returns the name and the unwrapped value of a binding: a +// declaration, an assignment to a plain name such as `client = new OpenAI()`, +// a class field, or an assignment to a property of this. +func typeScriptBinding(node *sitter.Node) (*sitter.Node, *sitter.Node) { + switch node.Kind() { + case "variable_declarator", "public_field_definition": + return node.ChildByFieldName("name"), typeScriptUnwrap(node.ChildByFieldName("value")) + case "required_parameter", "optional_parameter": + // `constructor(private client = new OpenAI())` declares and sets a + // property of the instance, and `run(client = new OpenAI())` binds + // a client in its function. + if typeScriptParameterProperty(node) || node.ChildByFieldName("value") != nil { + return node.ChildByFieldName("pattern"), typeScriptUnwrap(node.ChildByFieldName("value")) + } + case "assignment_pattern": + // A JavaScript parameter with a default is the pattern itself. + if parent := node.Parent(); parent != nil && parent.Kind() == "formal_parameters" { + return node.ChildByFieldName("left"), typeScriptUnwrap(node.ChildByFieldName("right")) + } + case "field_definition": + return node.ChildByFieldName("property"), typeScriptUnwrap(node.ChildByFieldName("value")) + case "assignment_expression": + left := node.ChildByFieldName("left") + if left != nil && (left.Kind() == "identifier" || typeScriptThisProperty(left)) { + return left, typeScriptUnwrap(node.ChildByFieldName("right")) + } + } + return nil, nil +} + +// typeScriptParameterProperty reports whether a parameter has an +// accessibility modifier or readonly, which makes it a property of the +// instance in a TypeScript constructor. +func typeScriptParameterProperty(parameter *sitter.Node) bool { + for index := uint(0); index < parameter.ChildCount(); index++ { + child := parameter.Child(index) + if child.Kind() == "accessibility_modifier" || !child.IsNamed() && child.Kind() == "readonly" { + return true + } + } + return false +} + +// typeScriptParameterOf returns the parameter of the nearest enclosing +// function that binds name, or false when the function has none. +func typeScriptParameterOf(node *sitter.Node, name string, source []byte) (*sitter.Node, bool) { + for parent := node.Parent(); parent != nil; parent = parent.Parent() { + switch parent.Kind() { + case "method_definition", "function_declaration", "generator_function_declaration", + "function_expression", "function", "generator_function", "arrow_function": + default: + continue + } + for _, parameter := range code.NamedChildren(parent.ChildByFieldName("parameters")) { + bound := parameter + switch parameter.Kind() { + case "required_parameter", "optional_parameter": + bound = parameter.ChildByFieldName("pattern") + case "assignment_pattern": + bound = parameter.ChildByFieldName("left") + } + if bound != nil && bound.Kind() == "identifier" && code.Text(bound, source) == name { + return parameter, true + } + } + return nil, false + } + return nil, false +} + +// typeScriptParameterKind returns the framework kind that the type annotation +// of a parameter names, such as `client: OpenAI`, or an empty string. +func typeScriptParameterKind(parameter, use *sitter.Node, imports typeScriptAliases, source []byte) string { + annotation := parameter.ChildByFieldName("type") + if annotation == nil { + return "" + } + kinds := imports.at(use, source) + kind := "" + // `client: OpenAI | undefined` and `client?: OpenAI` name the client + // through the one type of the annotation that an import binds. + code.Walk(annotation, func(node *sitter.Node) { + if kind != "" || node.Kind() != "type_identifier" && node.Kind() != "identifier" { + return + } + kind = kinds[code.Text(node, source)] + }) + return kind +} + +// typeScriptThisProperty reports whether a node is a property chain on this, +// such as this.client. +func typeScriptThisProperty(node *sitter.Node) bool { + for node != nil && node.Kind() == "member_expression" { + node = typeScriptUnwrap(node.ChildByFieldName("object")) + } + return node != nil && node.Kind() == "this" +} + +// typeScriptInstanceID names the variable, field, or property that a binding +// sets. A variable is scoped like a constant. A property of this belongs to +// the instance of the enclosing class, so every method of the class sees it +// under the class scope. +func typeScriptInstanceID(binding, name *sitter.Node, source []byte) string { + if typeScriptThisProperty(name) { + // `this!.client` and `this.client` name one property, the same one + // that a class field `client = ...` declares and that a use looks up + // as a member of the class. + property := strings.TrimPrefix(typeScriptCallableName(name, source), "this.") + return strings.Join(append(typeScriptMemberScopes(binding, source), property), ".") + } + if typeScriptParameterProperty(binding) { + // A parameter property is a property of the instance, as this.client is. + return strings.Join(append(typeScriptMemberScopes(binding, source), code.Text(name, source)), ".") + } + return typeScriptBindingID(binding, code.Text(name, source), source) +} + +// typeScriptClassScopes returns the scopes that enclose a node up to its +// innermost class, outermost first. A node outside every class keeps all of +// its scopes. +func typeScriptClassScopes(node *sitter.Node, source []byte) []string { + var scopes []string + inClass := false + for parent := node.Parent(); parent != nil; parent = parent.Parent() { + name := typeScriptScopeName(parent, source) + if name == "" { + continue + } + switch parent.Kind() { + case "class_declaration", "abstract_class_declaration", "class": + inClass = true + } + if inClass { + scopes = append([]string{name}, scopes...) + } + } + if !inClass { + return typeScriptScopes(node, source) + } + return scopes +} + +// typeScriptMemberScopes returns the scopes of a class member that a node +// reads or sets: the class scopes, with a static marker when the node sits +// in a static field or a static method, because instance code does not see +// the static members and static code does not see the instance members. +func typeScriptMemberScopes(node *sitter.Node, source []byte) []string { + scopes := typeScriptClassScopes(node, source) + for parent := node.Parent(); parent != nil && parent.Kind() != "class_body"; parent = parent.Parent() { + switch parent.Kind() { + case "class_static_block": + // `static { ... }` runs with the class, so this is the class. + return append(scopes, "static") + case "public_field_definition", "field_definition", "method_definition": + if typeScriptStaticField(parent, source) { + return append(scopes, "static") + } + return scopes + } + } + return scopes +} + +// typeScriptInstanceKind returns the kind of the instance that a receiver +// names at a use: the one of the enclosing scope when it exists, else the +// module-level one. this.client in a method also names a class field +// `client = new OpenAI()`. +func typeScriptInstanceKind(instances map[string]string, node *sitter.Node, receiver string, source []byte) string { + declared := func(id string) bool { _, ok := instances[id]; return ok } + // `Bot.client` reads the static field of the class, so the class name + // reaches the static marker first, as a constant read through it does. + // The instance field of the same name is `this.client`, not `Bot.client`. + if root, field, ok := strings.Cut(receiver, "."); ok && root != "this" { + if id := typeScriptScopedName(node, root+".static."+field, source, declared); declared(id) { + return instances[id] + } + } + // A binding in scope decides, with or without a kind, so a local mock + // or a `this.client = mock()` shadows the instance that it hides. + if id := typeScriptScopedName(node, receiver, source, declared); declared(id) { + return instances[id] + } + if field, ok := strings.CutPrefix(receiver, "this."); ok { + return instances[strings.Join(append(typeScriptMemberScopes(node, source), field), ".")] + } + return "" +} + +// typeScriptNumber reads a number literal, with a sign, numeric separators, +// a base prefix, and a BigInt suffix, as a float64. A value that it cannot +// read comes with the read-only reason: a sign on something other than a +// literal is a dynamic expression, and an integer beyond 53 bits has no exact +// float64, so it is not representable. +func typeScriptNumber(node *sitter.Node, source []byte) (float64, string) { + const dynamic, notRepresentable = "dynamic expression has no safe inverse", "number is not representable" + text := code.Text(node, source) + sign := 1.0 + if node.Kind() == "unary_expression" { + operand := node.ChildByFieldName("argument") + if operand == nil || operand.Kind() != "number" { + return 0, dynamic + } + switch { + case strings.HasPrefix(text, "-"): + sign = -1 + case strings.HasPrefix(text, "+"): + default: + return 0, dynamic + } + text = code.Text(operand, source) + } + text = strings.ToLower(strings.ReplaceAll(strings.TrimSuffix(text, "n"), "_", "")) + // A literal with a base prefix, or without a fraction and an exponent, is + // an integer. + if strings.HasPrefix(text, "0x") || strings.HasPrefix(text, "0o") || strings.HasPrefix(text, "0b") || + !strings.ContainsAny(text, ".e") { + integer, err := strconv.ParseInt(text, 0, 64) + if err != nil || !adapter.ExactInteger(integer) { + return 0, notRepresentable + } + return sign * float64(integer), "" + } + value, err := strconv.ParseFloat(text, 64) + if err != nil || math.IsInf(value, 0) { + return 0, notRepresentable + } + return sign * value, "" +} + +func typeScriptObject(node *sitter.Node, source []byte, constants map[string]*adapter.Node) *adapter.Node { + node = typeScriptUnwrap(node) + if node == nil { + return adapter.ReadOnly(0, 0, "object is missing") + } + if node.Kind() != "object" { + if value := typeScriptValue(node, source, constants); value.Fields != nil { + return value + } + return adapter.ReadOnly(int(node.StartByte()), int(node.EndByte()), "dynamic expression is not a literal object") + } + start, end := int(node.StartByte()), int(node.EndByte()) + fields := map[string]*adapter.Node{} + object := map[string]any{} + complete := true + for _, pair := range code.NamedChildren(node) { + if pair.Kind() == "shorthand_property_identifier" { + // `{ instructions }` is `{ instructions: instructions }`. + key := code.Text(pair, source) + if _, duplicate := fields[key]; duplicate { + return adapter.ReadOnly(start, end, "object contains a duplicate property") + } + value := typeScriptConstant(pair, key, int(pair.StartByte()), source, constants) + if value == nil { + value = adapter.ReadOnly(int(pair.StartByte()), int(pair.EndByte()), "dynamic expression has no safe inverse") + } + // A shorthand messages constant is a message list, as a literal + // messages array is. + if key == "messages" { + if messages := code.MessageList(value); messages != nil { + value = messages + } else { + value = adapter.ReadOnly(int(pair.StartByte()), int(pair.EndByte()), "messages are not a literal array") + } + } + fields[key] = value + if code.Writable(value) { + object[key] = value.Value + } else { + complete = false + } + continue + } + if pair.Kind() != "pair" { + fields[code.Text(pair, source)] = adapter.ReadOnly( + int(pair.StartByte()), int(pair.EndByte()), "object entry is dynamic", + ) + complete = false + continue + } + keyNode := pair.ChildByFieldName("key") + valueNode := typeScriptUnwrap(pair.ChildByFieldName("value")) + key, ok := typeScriptPropertyName(keyNode, source) + if !ok { + // A write rewrites the object as JSON with literal keys, so a + // computed key makes it read-only. + key = code.Text(keyNode, source) + complete = false + } + if _, duplicate := fields[key]; duplicate { + return adapter.ReadOnly(start, end, "object contains a duplicate property") + } + value := typeScriptValue(valueNode, source, constants) + if key == "messages" { + value = typeScriptMessages(valueNode, source, constants) + } + fields[key] = value + if code.Writable(value) { + object[key] = value.Value + } else { + complete = false + } + } + if !complete { + return &adapter.Node{ + Fields: fields, Start: start, End: end, Capability: adapter.CapabilityReadOnly, + Reason: "object contains a dynamic value", + } + } + return &adapter.Node{ + Value: object, Fields: fields, Start: start, End: end, Capability: adapter.CapabilityDirect, + Encode: adapter.JSONEncoder(source[start:end]), + } +} + +func typeScriptValue(node *sitter.Node, source []byte, constants map[string]*adapter.Node) *adapter.Node { + node = typeScriptUnwrap(node) + if node == nil { + return adapter.ReadOnly(0, 0, "value is missing") + } + start, end := int(node.StartByte()), int(node.EndByte()) + switch node.Kind() { + case "string", "template_string": + value, quote, err := code.DecodeJSString(source[start:end]) + if err != nil { + return adapter.ReadOnly(start, end, err.Error()) + } + return adapter.String(value, start, end, func(text string) ([]byte, error) { + encoded, err := code.EncodeJSString(text, quote) + return code.MatchLineEndings(encoded, source[start:end]), err + }) + case "number", "unary_expression": + value, reason := typeScriptNumber(node, source) + if reason != "" { + return adapter.ReadOnly(start, end, reason) + } + return adapter.Number(value, start, end, func(number float64) ([]byte, error) { + return []byte(strconv.FormatFloat(number, 'g', -1, 64)), nil + }) + case "object": + return typeScriptObject(node, source, constants) + case "array": + values := make([]any, 0, node.NamedChildCount()) + items := make([]*adapter.Node, 0, node.NamedChildCount()) + complete := true + for _, child := range code.NamedChildren(node) { + value := typeScriptValue(child, source, constants) + items = append(items, value) + values = append(values, value.Value) + if !code.Writable(value) { + complete = false + } + } + result := &adapter.Node{ + Value: values, Items: items, Collection: true, Start: start, End: end, Capability: adapter.CapabilityDirect, + Encode: adapter.JSONEncoder(source[start:end]), + } + if !complete { + result.Value = nil + result.Capability = adapter.CapabilityReadOnly + result.Reason = "array contains dynamic values" + result.Encode = nil + } + return result + case "true": + return adapter.Bool(true, start, end, nil) + case "false": + return adapter.Bool(false, start, end, nil) + case "null": + return &adapter.Node{Start: start, End: end, Capability: adapter.CapabilityDirect, Encode: adapter.JSONEncoder(nil)} + case "member_expression": + // Each segment of values.CONFIG.instructions is a static object. A + // wrapper on a segment, such as values.CONFIG!.prompt, changes nothing. + object := typeScriptUnwrap(node.ChildByFieldName("object")) + property := node.ChildByFieldName("property") + if object != nil && property != nil && object.Kind() == "identifier" && constants != nil { + // Prompts.system reads the static field of the class. + declared := func(id string) bool { _, ok := constants[id]; return ok } + name := code.Text(object, source) + ".static." + code.Text(property, source) + if id := typeScriptScopedName(node, name, source, declared); declared(id) { + if value := constants[id]; value != nil { + return value.Clone() + } + } + } + if object != nil && property != nil && object.Kind() == "this" && constants != nil { + // this.instructions in a method reads the class field constant. + id := strings.Join(append(typeScriptMemberScopes(node, source), code.Text(property, source)), ".") + if value := constants[id]; value != nil { + return value.Clone() + } + } + if object != nil && property != nil && + (object.Kind() == "identifier" || object.Kind() == "member_expression" || object.Kind() == "subscript_expression") { + if base := typeScriptValue(object, source, constants); base.Fields != nil { + if value := base.Fields[code.Text(property, source)]; value != nil { + return value + } + } + } + case "subscript_expression": + // CONFIG["prompt"] and LIST[1] read a static object or array. + object := typeScriptUnwrap(node.ChildByFieldName("object")) + index := typeScriptUnwrap(node.ChildByFieldName("index")) + if object != nil && index != nil { + base := typeScriptValue(object, source, constants) + switch key := typeScriptValue(index, source, nil).Value.(type) { + case string: + if value := base.Fields[key]; value != nil { + return value + } + case float64: + if position := int(key); float64(position) == key && position >= 0 && position < len(base.Items) { + return base.Items[position] + } + } + } + case "identifier": + if value := typeScriptConstant(node, code.Text(node, source), start, source, constants); value != nil { + return value + } + } + return adapter.ReadOnly(start, end, "dynamic expression has no safe inverse") +} + +// typeScriptMessages reads a message list: a literal array, or a constant or +// namespace member that holds one. +func typeScriptMessages(node *sitter.Node, source []byte, constants map[string]*adapter.Node) *adapter.Node { + node = typeScriptUnwrap(node) + if node == nil { + return adapter.ReadOnly(0, 0, "messages are missing") + } + if node.Kind() != "array" { + if messages := code.MessageList(typeScriptValue(node, source, constants)); messages != nil { + return messages + } + return adapter.ReadOnly(int(node.StartByte()), int(node.EndByte()), "messages are not a literal array") + } + start, end := int(node.StartByte()), int(node.EndByte()) + var items []*adapter.Node + var messages []syncdomain.Message + complete := true + for _, child := range code.NamedChildren(node) { + item := typeScriptMessage(child, source, constants) + items = append(items, item) + role, roleOK := item.Fields["role"].Value.(string) + content, contentOK := item.Fields["content"].Value.(string) + if !roleOK || !contentOK || !code.Writable(item.Fields["content"]) { + complete = false + continue + } + messages = append(messages, syncdomain.Message{ + Role: code.NormalizeRole(role), Content: content, + }) + } + result := &adapter.Node{ + Items: items, Collection: true, Start: start, End: end, Capability: adapter.CapabilityDirect, + Reason: "message structure is read-only", + } + if complete { + result.Value = messages + } else { + result.Capability = adapter.CapabilityReadOnly + result.Reason = "dynamic message content is read-only" + } + return result +} + +func typeScriptMessage(node *sitter.Node, source []byte, constants map[string]*adapter.Node) *adapter.Node { + node = typeScriptUnwrap(node) + fields := map[string]*adapter.Node{} + switch node.Kind() { + case "array": + children := code.NamedChildren(node) + if len(children) == 2 { + fields["role"] = typeScriptValue(children[0], source, constants) + fields["content"] = typeScriptValue(children[1], source, constants) + } + case "object": + if object := typeScriptObject(node, source, constants); object.Fields != nil { + fields = object.Fields + } + case "new_expression": + // `new SystemMessage("...")` and the other LangChain message classes + // carry their role in the class name and their content first. + role := "" + switch constructor := typeScriptCallable(node, source); { + case strings.HasSuffix(constructor, "SystemMessage"): + role = syncdomain.RoleSystem + case strings.HasSuffix(constructor, "HumanMessage"): + role = syncdomain.RoleUser + case strings.HasSuffix(constructor, "AIMessage"): + role = syncdomain.RoleAssistant + } + if arguments := code.NamedChildren(node.ChildByFieldName("arguments")); role != "" && len(arguments) != 0 { + fields["role"] = &adapter.Node{Value: role} + argument := typeScriptUnwrap(arguments[0]) + if argument != nil && argument.Kind() == "object" { + fields["content"] = typeScriptObject(argument, source, constants).Fields["content"] + } else { + fields["content"] = typeScriptValue(argument, source, constants) + } + } + } + if fields["role"] == nil { + fields["role"] = adapter.ReadOnly(int(node.StartByte()), int(node.EndByte()), "message role is not static") + } + if fields["content"] == nil { + fields["content"] = adapter.ReadOnly(int(node.StartByte()), int(node.EndByte()), "message content is not static") + } + return &adapter.Node{Fields: fields} +} + +func typeScriptPropertyName(node *sitter.Node, source []byte) (string, bool) { + if node == nil { + return "", false + } + switch node.Kind() { + case "property_identifier", "identifier": + return code.Text(node, source), true + case "string": + value := typeScriptValue(node, source, nil) + text, ok := value.Value.(string) + return text, ok + case "computed_property_name": + if inner := typeScriptUnwrap(node.NamedChild(0)); inner != nil && inner.Kind() == "string" { + return typeScriptPropertyName(inner, source) + } + return code.Text(node, source), false + case "number": + return code.Text(node, source), true + default: + return "", false + } +} diff --git a/internal/sync/adapter/ecmascript/ecmascript_test.go b/internal/sync/adapter/ecmascript/ecmascript_test.go new file mode 100644 index 000000000..16d606e3e --- /dev/null +++ b/internal/sync/adapter/ecmascript/ecmascript_test.go @@ -0,0 +1,2653 @@ +package ecmascript + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + sitter "github.com/tree-sitter/go-tree-sitter" + javascriptlanguage "github.com/tree-sitter/tree-sitter-javascript/bindings/go" + typescriptlanguage "github.com/tree-sitter/tree-sitter-typescript/bindings/go" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" + "github.com/launchdarkly/ldcli/internal/sync/adapter" + "github.com/launchdarkly/ldcli/internal/sync/adapter/code" +) + +func TestTypeScriptInspectorHandlesStaticAndDynamicLanguageForms(t *testing.T) { + source := []byte(` +import { PromptTemplate, ChatPromptTemplate } from "@langchain/core/prompts"; +import { tool } from "ai"; +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import textDefault, { TEXT, MISSING as missing } from "./values"; +import * as values from "./values"; +import external from "third-party"; + +const TRUE = true; +const FALSE = false; +const NOTHING = null; +const NUMBER = 1.5; +const OBJECT = { text: TEXT, "literal.key": "value", [TEXT]: "computed", 1: "number", ...values }; +const DUPLICATE = { name: "one", name: "two" }; +const DYNAMIC = [TEXT, makeValue()]; +const MUTATED = ["one"]; +MUTATED.push("two"); +MUTATED[0] = "changed"; + +const empty = PromptTemplate.fromTemplate(); +const chat = ChatPromptTemplate.fromMessages([["human", TEXT], { role: "ai", content: "Done" }, {}]); +const tools = { search: tool({ description: "Search", parameters: { type: "object" } }), dynamic: other }; + +const server = new Server({}); +server.registerTool("lookup", { description: TEXT, inputSchema: { type: "object" } }); +server.registerTool(dynamicName, {}); +`) + root, err := parseTypeScriptBundle(adapter.SourceBundle{ + Primary: adapter.Document{Path: "src/main.ts", Data: source}, + Dependencies: map[string]adapter.Document{ + "src/values.ts": {Path: "src/values.ts", Data: []byte(`export const TEXT = "Hello ${name}";`)}, + }, + }, true) + require.NoError(t, err) + require.NotNil(t, root) + assert.NotNil(t, root.Fields["tools"]) + assert.NotNil(t, root.Fields["prompts"]) + + dependencies, err := (ecmaScriptAdapter{format: "typescript", typed: true}).Dependencies( + adapter.Document{Path: "src/main.ts", Data: source}, + ) + require.NoError(t, err) + assert.Contains(t, dependencies, "src/values.ts") + + jsRoot, err := parseTypeScriptBundle(adapter.SourceBundle{ + Primary: adapter.Document{Path: "src/main.js", Data: []byte(`const value = {enabled: false};`)}, + }, false) + require.NoError(t, err) + require.NotNil(t, jsRoot) +} + +func TestTypeScriptValueSafetyBranches(t *testing.T) { + document := adapter.Document{Path: "main.ts", Data: []byte(` +const TRUE = true; +const OVERFLOW = 1e9999; +const TEMPLATE = ` + "`hello ${name}`" + `; +const OBJECT = { "name": "one", "name": "two", ...dynamic }; +const DYNAMIC_OBJECT = { static: "value", ...dynamic }; +const ARRAY = ["value", makeValue()]; +const MESSAGE = {}; +const NAMESPACE_VALUE = values.TEXT; +const IDENTIFIER_VALUE = STATIC_VALUE; +`)} + tree, err := code.ParseTree(document, sitter.NewLanguage(typescriptlanguage.LanguageTypescript())) + require.NoError(t, err) + defer tree.Close() + + trueNode := code.FindNode(tree.RootNode(), document.Data, "true", "true") + require.NotNil(t, trueNode) + value := typeScriptValue(trueNode, document.Data, nil) + encoded, err := value.Encode(false) + require.NoError(t, err) + assert.Equal(t, "false", string(encoded)) + _, err = value.Encode("false") + require.Error(t, err) + + overflow := code.FindNode(tree.RootNode(), document.Data, "number", "1e9999") + require.NotNil(t, overflow) + assert.Equal(t, adapter.CapabilityReadOnly, typeScriptValue(overflow, document.Data, nil).Capability) + template := code.FindNode(tree.RootNode(), document.Data, "template_string", "`hello ${name}`") + require.NotNil(t, template) + assert.Equal(t, adapter.CapabilityReadOnly, typeScriptValue(template, document.Data, nil).Capability) + + object := code.FindNode(tree.RootNode(), document.Data, "object", `{ "name": "one", "name": "two", ...dynamic }`) + require.NotNil(t, object) + assert.Equal(t, adapter.CapabilityReadOnly, typeScriptObject(object, document.Data, nil).Capability) + dynamicObject := code.FindNode(tree.RootNode(), document.Data, "object", `{ static: "value", ...dynamic }`) + require.NotNil(t, dynamicObject) + assert.Equal(t, adapter.CapabilityReadOnly, typeScriptObject(dynamicObject, document.Data, nil).Capability) + array := code.FindNode(tree.RootNode(), document.Data, "array", `["value", makeValue()]`) + require.NotNil(t, array) + assert.Equal(t, adapter.CapabilityReadOnly, typeScriptValue(array, document.Data, nil).Capability) + + member := code.FindNode(tree.RootNode(), document.Data, "member_expression", `values.TEXT`) + require.NotNil(t, member) + namespace := &adapter.Node{Fields: map[string]*adapter.Node{"TEXT": {Value: "resolved"}}} + assert.Equal(t, "resolved", typeScriptValue(member, document.Data, map[string]*adapter.Node{"values": namespace}).Value) + identifier := code.FindNode(tree.RootNode(), document.Data, "identifier", "STATIC_VALUE") + require.NotNil(t, identifier) + assert.Equal(t, "resolved", typeScriptValue(identifier, document.Data, map[string]*adapter.Node{ + "STATIC_VALUE": {Value: "resolved", End: 1}, + }).Value) + + assert.Equal(t, adapter.CapabilityReadOnly, typeScriptObject(nil, nil, nil).Capability) + assert.Equal(t, adapter.CapabilityReadOnly, typeScriptMessages(nil, nil, nil).Capability) + assert.Equal(t, adapter.CapabilityReadOnly, typeScriptMessages(object, document.Data, nil).Capability) + messageObject := code.FindNode(tree.RootNode(), document.Data, "object", `{}`) + require.NotNil(t, messageObject) + message := typeScriptMessage(messageObject, document.Data, nil) + assert.Equal(t, adapter.CapabilityReadOnly, message.Fields["role"].Capability) + assert.Equal(t, adapter.CapabilityReadOnly, message.Fields["content"].Capability) + _, ok := typeScriptPropertyName(nil, nil) + assert.False(t, ok) + assert.Empty(t, typeScriptMutationRoot(nil, nil)) +} + +func TestTypeScriptMCPServerRegistrationBranches(t *testing.T) { + source := []byte(` +import { McpServer } from "@modelcontextprotocol/sdk"; +const server = new McpServer({}); +server.registerTool(); +server.registerTool(dynamicName, {}); +server.registerTool("lookup", { description: "Find data" }); +`) + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "server.ts", Data: source}}, true) + require.NoError(t, err) + require.Len(t, root.Fields["tools"].Items, 1) + assert.Equal(t, "lookup", root.Fields["tools"].Items[0].Fields["id"].Value) +} + +func TestTypeScriptImportClausesBindEveryForm(t *testing.T) { + document := adapter.Document{Path: "main.ts", Data: []byte(` +import OpenAI, { toFile as tf, type Foo } from "openai"; +import type { Bar } from "./types"; +import * as values from "./values"; +import def, { TEXT as text } from "./values"; +import "side-effect"; +`)} + tree, err := code.ParseTree(document, sitter.NewLanguage(typescriptlanguage.LanguageTypescript())) + require.NoError(t, err) + defer tree.Close() + + assert.Equal(t, map[string]string{"OpenAI": "openai-client"}, typeScriptImports(tree.RootNode(), document.Data).at(tree.RootNode(), document.Data)) + assert.Equal(t, []typeScriptImportBinding{ + {module: "./values", name: "*", alias: "values"}, + {module: "./values", name: "default", alias: "def"}, + {module: "./values", name: "TEXT", alias: "text"}, + }, typeScriptImportBindings(tree.RootNode(), document.Data)) +} + +func TestTypeScriptNullKeepsObjectsAndArraysWritable(t *testing.T) { + document := adapter.Document{Path: "main.ts", Data: []byte(`const CONFIG = { stop: null, items: [null, 1] };`)} + tree, err := code.ParseTree(document, sitter.NewLanguage(typescriptlanguage.LanguageTypescript())) + require.NoError(t, err) + defer tree.Close() + + object := code.FindNode(tree.RootNode(), document.Data, "object", `{ stop: null, items: [null, 1] }`) + require.NotNil(t, object) + value := typeScriptObject(object, document.Data, nil) + assert.Equal(t, adapter.CapabilityDirect, value.Capability) + assert.Equal(t, map[string]any{"stop": nil, "items": []any{nil, 1.0}}, value.Value) + encoded, err := value.Encode(map[string]any{"stop": nil, "items": []any{nil, 2.0}}) + require.NoError(t, err) + assert.Contains(t, string(encoded), "null") +} + +func TestTypeScriptFunctionExpressionsQualifyLocalConstants(t *testing.T) { + document := adapter.Document{Path: "main.ts", Data: []byte(` +const first = async () => { const prompt = "a"; }; +const second = function () { const prompt = "b"; }; +const handlers = { third: () => { const prompt = "c"; } }; +app.get("/", () => { const prompt = "d"; }); +app.get("/x", () => { const prompt = "e"; }); +`)} + tree, err := code.ParseTree(document, sitter.NewLanguage(typescriptlanguage.LanguageTypescript())) + require.NoError(t, err) + defer tree.Close() + + constants, _ := typeScriptConstants(tree.RootNode(), document.Data, nil) + assert.Equal(t, "a", constants["first.prompt"].Value) + assert.Equal(t, "b", constants["second.prompt"].Value) + assert.Equal(t, "c", constants["third.prompt"].Value) + // Two callbacks that are only arguments share one unqualified id, so + // neither constant is usable. + assert.Nil(t, constants["prompt"]) +} + +func TestTypeScriptImportedConstantsResolveRegardlessOfPosition(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{ + Primary: adapter.Document{Path: "src/main.ts", Data: []byte(` +import { Agent } from "@openai/agents"; +import { TEXT } from "./values"; +const support = new Agent({ name: "support", instructions: TEXT }); +`)}, + Dependencies: map[string]adapter.Document{ + // The constant ends after the use in the primary file. + "src/values.ts": {Path: "src/values.ts", Data: []byte("// " + strings.Repeat("x", 300) + "\nexport const TEXT = \"Hello\";\n")}, + }, + }, true) + require.NoError(t, err) + require.Len(t, root.Fields["agents"].Items, 1) + instructions := root.Fields["agents"].Items[0].Fields["instructions"] + assert.Equal(t, "Hello", instructions.Value) + assert.Equal(t, "src/values.ts", instructions.File) +} + +func TestTypeScriptTypeWrappersKeepStaticValues(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: []byte(` +import { Agent } from "@openai/agents"; +const CONFIG = { stop: ["a"] as const, name: ("x")!, deep: ({ ok: true }) } satisfies Config; +const support = new Agent({ name: "support", instructions: "Help" } as const); +`)}}, true) + require.NoError(t, err) + + constants := root.Fields["constants"].Items + require.Len(t, constants, 1) + assert.Equal(t, []any{"a"}, constants[0].Fields["stop"].Value) + assert.Equal(t, "x", constants[0].Fields["name"].Value) + assert.Equal(t, map[string]any{"ok": true}, constants[0].Fields["deep"].Value) + require.Len(t, root.Fields["agents"].Items, 1) + assert.Equal(t, "Help", root.Fields["agents"].Items[0].Fields["instructions"].Value) +} + +func TestTypeScriptComputedKeysMakeObjectsReadOnly(t *testing.T) { + document := adapter.Document{Path: "main.ts", Data: []byte(`const A = { [key]: 1 };` + "\n" + `const B = { ["lit-key"]: 2 };`)} + tree, err := code.ParseTree(document, sitter.NewLanguage(typescriptlanguage.LanguageTypescript())) + require.NoError(t, err) + defer tree.Close() + + computed := code.FindNode(tree.RootNode(), document.Data, "object", `{ [key]: 1 }`) + require.NotNil(t, computed) + assert.Equal(t, adapter.CapabilityReadOnly, typeScriptObject(computed, document.Data, nil).Capability) + literal := code.FindNode(tree.RootNode(), document.Data, "object", `{ ["lit-key"]: 2 }`) + require.NotNil(t, literal) + value := typeScriptObject(literal, document.Data, nil) + assert.Equal(t, adapter.CapabilityDirect, value.Capability) + assert.Equal(t, map[string]any{"lit-key": 2.0}, value.Value) +} + +func TestTypeScriptScopesResolveUsesAndMutationsAlike(t *testing.T) { + for _, language := range []struct { + name string + typed bool + source string + }{ + {"typescript", true, ` +import { TEXT } from "./values"; +const LIST = ["one"]; +const MESSAGES = [["human", TEXT]]; +class K { field = () => { const prompt = "class"; return prompt; }; } +function build() { + const prompt = "local"; + LIST.push("two"); + return { prompt, list: LIST, imported: TEXT }; +} +`}, + {"javascript", false, ` +import { TEXT } from "./values"; +const LIST = ["one"]; +const MESSAGES = [["human", TEXT]]; +class K { field = () => { const prompt = "class"; return prompt; }; } +function build() { + const prompt = "local"; + LIST.push("two"); + return { prompt, list: LIST, imported: TEXT }; +} +`}, + } { + t.Run(language.name, func(t *testing.T) { + extension := ".ts" + if !language.typed { + extension = ".js" + } + bundle := adapter.SourceBundle{ + Primary: adapter.Document{Path: "src/main" + extension, Data: []byte(language.source)}, + Dependencies: map[string]adapter.Document{ + "src/values" + extension: {Path: "src/values" + extension, Data: []byte(`export const TEXT = "Hello";`)}, + }, + } + imported := typeScriptImportedValues(bundle, language.typed) + tree, err := code.ParseTree(bundle.Primary, bundleLanguage(language.typed)) + require.NoError(t, err) + defer tree.Close() + + constants, _ := typeScriptConstants(tree.RootNode(), bundle.Primary.Data, imported) + assert.Nil(t, constants["LIST"], "a mutation inside a function marks the module-level list") + assert.Equal(t, "local", constants["build.prompt"].Value) + assert.Equal(t, "class", constants["K.field.prompt"].Value, "a class field arrow names the class and the field in both languages") + messages := code.MessageList(constants["MESSAGES"]) + require.NotNil(t, messages) + assert.Equal(t, adapter.CapabilityDirect, messages.Capability, "an imported value inside a constant resolves") + + use := code.FindNode(tree.RootNode(), bundle.Primary.Data, "shorthand_property_identifier", "prompt") + require.NotNil(t, use) + for name, value := range imported { + constants[name] = value + } + declared := func(id string) bool { _, ok := constants[id]; return ok } + assert.Equal(t, "build.prompt", typeScriptScopedName(use, "prompt", bundle.Primary.Data, declared)) + }) + } +} + +func TestTypeScriptReturnedPromptCallsAreCollected(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: []byte(` +import { PromptTemplate } from "@langchain/core/prompts"; +function build() { + return PromptTemplate.fromTemplate("Hello {name}"); +} +`)}}, true) + require.NoError(t, err) + require.Len(t, root.Fields["prompts"].Items, 1) + prompt := root.Fields["prompts"].Items[0] + assert.Equal(t, "build.PromptTemplate.fromTemplate", prompt.Fields["id"].Value) + assert.Equal(t, "Hello {{name}}", prompt.Fields["template"].Value) +} + +func bundleLanguage(typed bool) *sitter.Language { + if typed { + return sitter.NewLanguage(typescriptlanguage.LanguageTypescript()) + } + return sitter.NewLanguage(javascriptlanguage.Language()) +} + +func TestTypeScriptNonObjectConfigsDoNotPanic(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: []byte(` +import { McpServer } from "@modelcontextprotocol/sdk"; +import { Agent } from "@openai/agents"; +import { ChatPromptTemplate } from "@langchain/core/prompts"; +const CONFIG = { description: "From a constant" }; +const server = new McpServer({}); +server.registerTool("constant", CONFIG); +server.registerTool("dynamic", buildConfig()); +server.registerTool("duplicate", { description: "one", description: "two" }); +const support = new Agent({ name: "support", name: "again" }); +const chat = ChatPromptTemplate.fromMessages([{ role: "human", role: "ai", content: "x" }]); +`)}}, true) + require.NoError(t, err) + + tools := map[string]*adapter.Node{} + for _, tool := range root.Fields["tools"].Items { + tools[tool.Fields["id"].Value.(string)] = tool + } + assert.Equal(t, "From a constant", tools["constant"].Fields["description"].Value, "a constant object resolves") + assert.Len(t, tools["dynamic"].Fields, 1, "a dynamic config has only the id") + assert.Len(t, tools["duplicate"].Fields, 1) + assert.Len(t, root.Fields["agents"].Items, 1) + assert.Len(t, root.Fields["prompts"].Items, 1) +} + +func TestTypeScriptMutationOfACollectionLeavesTheImportedValueWritable(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{ + Primary: adapter.Document{Path: "src/main.ts", Data: []byte(` +import { Agent } from "@openai/agents"; +import { TEXT } from "./values"; +const SYSTEM = "You help."; +const MESSAGES = [["system", SYSTEM], ["human", TEXT]]; +const LIST = [TEXT]; +LIST.push("more"); +const support = new Agent({ name: "support", instructions: TEXT }); +`)}, + Dependencies: map[string]adapter.Document{ + "src/values.ts": {Path: "src/values.ts", Data: []byte(`export const TEXT = "Hello";`)}, + }, + }, true) + require.NoError(t, err) + instructions := root.Fields["agents"].Items[0].Fields["instructions"] + assert.Equal(t, adapter.CapabilityDirect, instructions.Capability, "the mutated list does not mark the import") + for _, constant := range root.Fields["constants"].Items { + if constant.Fields["id"].Value == "MESSAGES" { + messages := code.MessageList(constant) + require.NotNil(t, messages) + assert.Equal(t, adapter.CapabilityDirect, messages.Capability, "a local system prompt resolves") + } + } +} + +func TestTypeScriptFrameworkCallsAcceptConstantConfigsAndMessageLists(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{ + Primary: adapter.Document{Path: "src/main.ts", Data: []byte(` +import { Agent } from "@openai/agents"; +import { generateText } from "ai"; +import { ChatPromptTemplate } from "@langchain/core/prompts"; +import * as values from "./values"; +const CONFIG = { name: "support", instructions: "Help" }; +const SHARED = [["human", "Hi"]]; +const support = new Agent(CONFIG); +const text = generateText({ messages: SHARED }); +const chat = ChatPromptTemplate.fromMessages(values.MESSAGES); +`)}, + Dependencies: map[string]adapter.Document{ + "src/values.ts": {Path: "src/values.ts", Data: []byte(`export const MESSAGES = [{ role: "system", content: "Be kind." }];`)}, + }, + }, true) + require.NoError(t, err) + + require.Len(t, root.Fields["agents"].Items, 1) + assert.Equal(t, "Help", root.Fields["agents"].Items[0].Fields["instructions"].Value) + prompts := map[string]*adapter.Node{} + for _, prompt := range root.Fields["prompts"].Items { + prompts[prompt.Fields["id"].Value.(string)] = prompt + } + assert.Equal(t, []syncdomain.Message{{Role: syncdomain.RoleUser, Content: "Hi"}}, prompts["text"].Fields["messages"].Value) + assert.Equal(t, []syncdomain.Message{{Role: syncdomain.RoleSystem, Content: "Be kind."}}, prompts["chat"].Fields["messages"].Value) +} + +func TestTSXFilesUseTheTSXGrammar(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{ + Primary: adapter.Document{Path: "src/App.tsx", Data: []byte(` +import { Agent } from "@openai/agents"; +import { TEXT } from "./values"; +export const View = () =>
{TEXT}
; +const support = new Agent({ name: "support", instructions: TEXT }); +`)}, + Dependencies: map[string]adapter.Document{ + "src/values.tsx": {Path: "src/values.tsx", Data: []byte("export const TEXT = \"Hello\";\nexport const Tag = () => ;\n")}, + }, + }, true) + require.NoError(t, err) + require.Len(t, root.Fields["agents"].Items, 1) + assert.Equal(t, "Hello", root.Fields["agents"].Items[0].Fields["instructions"].Value) +} + +func TestTypeScriptLocalDeclarationShadowsAnImport(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{ + Primary: adapter.Document{Path: "src/main.ts", Data: []byte(` +import { Agent } from "@openai/agents"; +import { TEXT as imported } from "./values"; +const TEXT = "local"; +const support = new Agent({ name: "support", instructions: TEXT }); +`)}, + Dependencies: map[string]adapter.Document{ + "src/values.ts": {Path: "src/values.ts", Data: []byte(`export const TEXT = "imported";`)}, + }, + }, true) + require.NoError(t, err) + assert.Equal(t, "local", root.Fields["agents"].Items[0].Fields["instructions"].Value) +} + +func TestTypeScriptAwaitedAndNestedCallsAreResources(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: []byte(` +import { generateText } from "ai"; +import { Agent } from "@openai/agents"; +import OpenAI from "openai"; +const client = new OpenAI(); +async function run() { + const result = await generateText({ model, prompt: "Hello" }); + const reply = await client.chat.completions.create({ model: "gpt", messages: [{ role: "user", content: "Hi" }] }); + return result; +} +const team = [new Agent({ name: "writer", instructions: "Write" }), new Agent({ name: "editor", instructions: "Edit" })]; +`)}}, true) + require.NoError(t, err) + ids := map[string]bool{} + for _, prompt := range root.Fields["prompts"].Items { + ids[prompt.Fields["id"].Value.(string)] = true + } + assert.True(t, ids["run.result"], "an awaited framework call takes the variable name") + assert.True(t, ids["run.reply"], "an awaited client request takes the variable name") + assert.Len(t, root.Fields["prompts"].Items, 2, "each call is a resource once") + assert.Len(t, root.Fields["agents"].Items, 2, "each agent in the array is a resource") +} + +func TestTypeScriptNamespaceMembersAreCopiedPerUse(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{ + Primary: adapter.Document{Path: "src/main.ts", Data: []byte(` +import { Agent } from "@openai/agents"; +import * as values from "./values"; +const first = new Agent(values.CONFIG); +const second = new Agent(values.CONFIG); +`)}, + Dependencies: map[string]adapter.Document{ + "src/values.ts": {Path: "src/values.ts", Data: []byte(`export const CONFIG = { name: "shared", instructions: "Help ${name}" };`)}, + }, + }, true) + require.NoError(t, err) + require.Len(t, root.Fields["agents"].Items, 2) + ids := []string{} + for _, agent := range root.Fields["agents"].Items { + ids = append(ids, agent.Fields["id"].Value.(string)) + assert.Equal(t, "Help ${name}", agent.Fields["instructions"].Value, "the Agents SDK sends instructions as they are") + } + assert.ElementsMatch(t, []string{"first", "second"}, ids, "the variable names the resource") + root.Fields["agents"].Items[0].Fields["instructions"].MarkReadOnly("one use changes") + assert.Equal(t, adapter.CapabilityDirect, root.Fields["agents"].Items[1].Fields["instructions"].Capability, "the other use is its own copy") +} + +func TestTypeScriptPromptConstructorAndMCPPromptAcceptConstants(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: []byte(` +import { ChatPromptTemplate } from "@langchain/core/prompts"; +import { McpServer } from "@modelcontextprotocol/sdk"; +const SHARED = [["human", "Hi"]]; +const PROMPT_CONFIG = { description: "Greets" }; +const chat = new ChatPromptTemplate(SHARED); +const server = new McpServer({}); +server.registerPrompt("greet", PROMPT_CONFIG, handler); +`)}}, true) + require.NoError(t, err) + prompts := map[string]*adapter.Node{} + for _, prompt := range root.Fields["prompts"].Items { + prompts[prompt.Fields["id"].Value.(string)] = prompt + } + require.NotNil(t, prompts["chat"]) + assert.Equal(t, []syncdomain.Message{{Role: syncdomain.RoleUser, Content: "Hi"}}, prompts["chat"].Fields["messages"].Value) + require.NotNil(t, prompts["greet"]) + assert.Equal(t, "Greets", prompts["greet"].Fields["description"].Value) +} + +func TestTypeScriptNestedFunctionPromptsKeepTheirOwnIDs(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: []byte(` +import { PromptTemplate } from "@langchain/core/prompts"; +const factory = () => { + function helper() { + return PromptTemplate.fromTemplate("inner"); + } + return PromptTemplate.fromTemplate("outer"); +}; +`)}}, true) + require.NoError(t, err) + prompts := map[string]*adapter.Node{} + for _, prompt := range root.Fields["prompts"].Items { + prompts[prompt.Fields["id"].Value.(string)] = prompt + } + require.Len(t, prompts, 2) + require.NotNil(t, prompts["factory"], "the prompt of the assigned function keeps the variable id") + assert.Equal(t, "outer", prompts["factory"].Fields["template"].Value) + assert.False(t, adapter.IsPositionalID(prompts["factory"])) + require.NotNil(t, prompts["factory.helper.PromptTemplate.fromTemplate"], "a prompt in a nested function has its own id") + assert.Equal(t, "inner", prompts["factory.helper.PromptTemplate.fromTemplate"].Fields["template"].Value) +} + +func TestTypeScriptShorthandDuplicatesMakeTheObjectReadOnly(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: []byte(` +import { Agent } from "@openai/agents"; +const instructions = "Shorthand"; +const support = new Agent({ instructions: "Help", instructions }); +`)}}, true) + require.NoError(t, err) + require.Len(t, root.Fields["agents"].Items, 1) + agent := root.Fields["agents"].Items[0] + assert.Len(t, agent.Fields, 1, "a shorthand duplicate of a key makes the object read-only, so the agent has only its id") + assert.Nil(t, agent.Fields["instructions"]) +} + +func TestTypeScriptSubscriptedMutationsFreezeTheRootConstant(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: []byte(` +const CONFIG = { items: ["a"] }; +CONFIG["items"].push("b"); +const LIST = [["a"]]; +LIST[0].push("b"); +const KEPT = ["a"]; +`)}}, true) + require.NoError(t, err) + constants := map[string]*adapter.Node{} + for _, constant := range root.Fields["constants"].Items { + constants[constant.Fields["id"].Value.(string)] = constant + } + assert.Equal(t, adapter.CapabilityReadOnly, constants["CONFIG"].Capability, "a mutating call through a subscript freezes the object") + assert.Equal(t, adapter.CapabilityReadOnly, constants["LIST"].Capability, "a mutating call through an index freezes the array") + assert.Equal(t, adapter.CapabilityDirect, constants["KEPT"].Capability) +} + +func TestTypeScriptStaticFieldsResolveThroughTheClassName(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: []byte(` +import { Agent } from "@openai/agents"; +class Prompts { + static system = "Help"; + static items = ["a"]; + static kept = ["k"]; +} +Prompts.items.push("b"); +const agent = new Agent({ instructions: Prompts.system }); +`)}}, true) + require.NoError(t, err) + require.Len(t, root.Fields["agents"].Items, 1) + instructions := root.Fields["agents"].Items[0].Fields["instructions"] + assert.Equal(t, "Help", instructions.Value, "a read through the class name resolves the static field") + assert.Equal(t, adapter.CapabilityDirect, instructions.Capability) + constants := map[string]*adapter.Node{} + for _, constant := range root.Fields["constants"].Items { + constants[constant.Fields["id"].Value.(string)] = constant + } + assert.Equal(t, adapter.CapabilityReadOnly, constants["Prompts.static.items"].Capability, "a mutating call through the class name freezes the static field") + assert.Equal(t, adapter.CapabilityDirect, constants["Prompts.static.kept"].Capability) +} + +func TestTypeScriptImportEqualsBindsEveryExport(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{ + Primary: adapter.Document{Path: "src/main.ts", Data: []byte(` +import agents = require("@openai/agents"); +import values = require("./values"); +const support = new agents.Agent({ instructions: values.TEXT }); +`)}, + Dependencies: map[string]adapter.Document{ + "src/values.ts": {Path: "src/values.ts", Data: []byte("export const TEXT = \"Hello\";\n")}, + }, + }, true) + require.NoError(t, err) + require.Len(t, root.Fields["agents"].Items, 1, "an import-equals of a framework binds its symbols") + assert.Equal(t, "Hello", root.Fields["agents"].Items[0].Fields["instructions"].Value, "an import-equals of a repository module reaches its exports") +} + +func TestTypeScriptNamespaceMutationsFreezeOneExport(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{ + Primary: adapter.Document{Path: "src/main.ts", Data: []byte(` +import { Agent } from "@openai/agents"; +import * as values from "./values"; +values.LIST.push("b"); +const agent = new Agent({ instructions: values.PROMPT }); +const copy = [values.LIST]; +`)}, + Dependencies: map[string]adapter.Document{ + "src/values.ts": {Path: "src/values.ts", Data: []byte("export const LIST = [\"a\"];\nexport const PROMPT = \"Help\";\n")}, + }, + }, true) + require.NoError(t, err) + require.Len(t, root.Fields["agents"].Items, 1) + instructions := root.Fields["agents"].Items[0].Fields["instructions"] + assert.Equal(t, "Help", instructions.Value, "an untouched export of the namespace stays writable") + assert.Equal(t, adapter.CapabilityDirect, instructions.Capability) + constants := map[string]*adapter.Node{} + for _, constant := range root.Fields["constants"].Items { + constants[constant.Fields["id"].Value.(string)] = constant + } + assert.Equal(t, adapter.CapabilityReadOnly, constants["copy"].Capability, "the mutated export is read-only") +} + +func TestTypeScriptMCPPromptMethodIsCollected(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: []byte(` +import { McpServer } from "@modelcontextprotocol/sdk"; +const server = new McpServer({}); +server.prompt("summarize", "Summarizes text", { text: z.string() }, handler); +server.prompt("plain", handler); +`)}}, true) + require.NoError(t, err) + prompts := map[string]*adapter.Node{} + for _, prompt := range root.Fields["prompts"].Items { + prompts[prompt.Fields["id"].Value.(string)] = prompt + } + require.NotNil(t, prompts["summarize"], "the older prompt method is a prompt") + assert.Equal(t, "Summarizes text", prompts["summarize"].Fields["description"].Value) + require.NotNil(t, prompts["plain"]) + assert.Nil(t, prompts["plain"].Fields["description"]) +} + +func TestTypeScriptInstancesResolveByScope(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: []byte(` +import OpenAI from "openai"; +import Anthropic from "@anthropic-ai/sdk"; +const client = new OpenAI(); +const first = client.chat.completions.create({ model: "gpt", messages: [{ role: "user", content: "one" }] }); +function other() { + const client = new Anthropic(); + return client.messages.create({ model: "claude", messages: [{ role: "user", content: "two" }] }); +} +let shared = new OpenAI(); +shared = new Anthropic(); +const third = shared.messages.create({ model: "claude", messages: [{ role: "user", content: "three" }] }); +`)}}, true) + require.NoError(t, err) + ids := map[string]bool{} + for _, prompt := range root.Fields["prompts"].Items { + ids[prompt.Fields["id"].Value.(string)] = true + } + assert.True(t, ids["first"]) + assert.True(t, ids["other.client.messages.create"], "the local client is an Anthropic client") +} + +func TestTypeScriptBlockScopedClientsKeepTheirOwnKinds(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: []byte(` +import OpenAI from "openai"; +import Anthropic from "@anthropic-ai/sdk"; +function run(flag: boolean) { + if (flag) { + const client = new OpenAI(); + return client.chat.completions.create({ model: "gpt", messages: [{ role: "user", content: "one" }] }); + } else { + const client = new Anthropic(); + return client.messages.create({ model: "claude", messages: [{ role: "user", content: "two" }] }); + } +} +`)}}, true) + require.NoError(t, err) + ids := []string{} + for _, prompt := range root.Fields["prompts"].Items { + ids = append(ids, prompt.Fields["id"].Value.(string)) + } + assert.ElementsMatch(t, []string{"run.client.chat.completions.create", "run.client.messages.create"}, ids, "each block has its own client") +} + +func TestJavaScriptBlockRequiresBindInTheirBlockOnly(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "src/main.js", Data: []byte(` +function build(flag) { + if (flag) { + const { Agent } = require("@openai/agents"); + return new Agent({ instructions: "inside" }); + } + return new Agent({ instructions: "outside" }); +} +`)}}, false) + require.NoError(t, err) + require.Len(t, root.Fields["agents"].Items, 1, "a require in a block is not visible after the block") + assert.Equal(t, "inside", root.Fields["agents"].Items[0].Fields["instructions"].Value) +} + +func TestJavaScriptModuleRequiresBindEveryExport(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{ + Primary: adapter.Document{Path: "src/main.js", Data: []byte(` +const agents = require("@openai/agents"); +const Agent = require("@openai/agents").Agent; +const values = require("./values"); +const support = new agents.Agent({ instructions: values.TEXT }); +const other = new Agent({ instructions: "Member" }); +`)}, + Dependencies: map[string]adapter.Document{ + "src/values.js": {Path: "src/values.js", Data: []byte("const TEXT = \"Hello\";\nmodule.exports = { TEXT };\n")}, + }, + }, false) + require.NoError(t, err) + agents := map[string]*adapter.Node{} + for _, agent := range root.Fields["agents"].Items { + agents[agent.Fields["id"].Value.(string)] = agent + } + require.Len(t, agents, 2, "a module require binds every export, and a member require binds one") + assert.Equal(t, "Hello", agents["support"].Fields["instructions"].Value, "a module require of a repository file reaches its exports") + assert.Equal(t, "Member", agents["other"].Fields["instructions"].Value) +} + +func TestJavaScriptModuleExportsAreImportable(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{ + Primary: adapter.Document{Path: "src/main.js", Data: []byte(` +const { Agent } = require("@openai/agents"); +const { TEXT, OTHER, INLINE } = require("./values"); +const { DIRECT, NAMED } = require("./exported"); +const first = new Agent({ instructions: TEXT }); +const second = new Agent({ instructions: OTHER }); +const third = new Agent({ instructions: INLINE }); +const fourth = new Agent({ instructions: DIRECT }); +const fifth = new Agent({ instructions: NAMED }); +`)}, + Dependencies: map[string]adapter.Document{ + "src/values.js": {Path: "src/values.js", Data: []byte(`const TEXT = "Hello"; +const LOCAL = "Local"; +module.exports = { TEXT, OTHER: LOCAL, INLINE: "Inline" }; +`)}, + "src/exported.js": {Path: "src/exported.js", Data: []byte(`exports.DIRECT = "Direct"; +const VALUE = "Named"; +module.exports.NAMED = VALUE; +`)}, + }, + }, false) + require.NoError(t, err) + agents := map[string]*adapter.Node{} + for _, agent := range root.Fields["agents"].Items { + agents[agent.Fields["id"].Value.(string)] = agent + } + for id, text := range map[string]string{"first": "Hello", "second": "Local", "third": "Inline", "fourth": "Direct", "fifth": "Named"} { + require.NotNil(t, agents[id], id) + assert.Equal(t, text, agents[id].Fields["instructions"].Value, "%s reads a CommonJS export", id) + } +} + +func TestJavaScriptRequireDefaultsBindFrameworkImports(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "src/main.js", Data: []byte(` +const { Agent = Fallback, tool: makeTool = other } = require("@openai/agents"); +const support = new Agent({ instructions: "Help" }); +`)}}, false) + require.NoError(t, err) + require.Len(t, root.Fields["agents"].Items, 1, "a destructuring default still binds the framework import") + assert.Equal(t, "Help", root.Fields["agents"].Items[0].Fields["instructions"].Value) +} + +func TestDefaultParametersAreClientInstances(t *testing.T) { + typed, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: []byte(` +import OpenAI from "openai"; +function run(client = new OpenAI()) { + return client.chat.completions.create({ model: "gpt", messages: [{ role: "user", content: "Hi" }] }); +} +`)}}, true) + require.NoError(t, err) + require.Len(t, typed.Fields["prompts"].Items, 1, "a TypeScript default parameter is a client of the function") + + plain, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.js", Data: []byte(` +import Anthropic from "@anthropic-ai/sdk"; +function run(client = new Anthropic()) { + return client.messages.create({ model: "claude", messages: [{ role: "user", content: "Hi" }] }); +} +`)}}, false) + require.NoError(t, err) + require.Len(t, plain.Fields["prompts"].Items, 1, "a JavaScript default parameter is a client of the function") +} + +func TestTypeScriptStaticAndInstanceMembersStayApart(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: []byte(` +import OpenAI from "openai"; +import Anthropic from "@anthropic-ai/sdk"; +class Bot { + static client = new Anthropic(); + client = new OpenAI(); + run() { + return this.client.chat.completions.create({ model: "gpt", messages: [{ role: "user", content: "instance" }] }); + } + static make() { + return this.client.messages.create({ model: "claude", messages: [{ role: "user", content: "static" }] }); + } +} +`)}}, true) + require.NoError(t, err) + contents := []string{} + for _, prompt := range root.Fields["prompts"].Items { + messages := prompt.Fields["messages"].Value.([]syncdomain.Message) + contents = append(contents, messages[0].Content) + } + assert.ElementsMatch(t, []string{"instance", "static"}, contents, "the static client and the instance client are two bindings") +} + +func TestTypeScriptClassFieldsDoNotHideImports(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: []byte(` +import { Agent } from "@openai/agents"; +import OpenAI from "openai"; +class Bot { + Agent = "label"; + client = new OpenAI(); + make() { + return new Agent({ instructions: "Help" }); + } + close() { + this.client = null; + } + ask() { + return this.client.chat.completions.create({ model: "gpt", messages: [{ role: "user", content: "Hi" }] }); + } +} +`)}}, true) + require.NoError(t, err) + require.Len(t, root.Fields["agents"].Items, 1, "a field named Agent does not hide the import in a method") + assert.Equal(t, "Help", root.Fields["agents"].Items[0].Fields["instructions"].Value) + require.Len(t, root.Fields["prompts"].Items, 1, "a reset to null does not change the kind of the client") +} + +func TestTypeScriptStaticThisBindingsAreClients(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: []byte(` +import OpenAI from "openai"; +import Anthropic from "@anthropic-ai/sdk"; +class Bot { + static { + this.shared = new Anthropic(); + } + static make() { + this.client = new OpenAI(); + const first = this.client.chat.completions.create({ model: "gpt", messages: [{ role: "user", content: "assigned" }] }); + return this.shared.messages.create({ model: "claude", messages: [{ role: "user", content: "block" }] }); + } +} +`)}}, true) + require.NoError(t, err) + contents := []string{} + for _, prompt := range root.Fields["prompts"].Items { + messages := prompt.Fields["messages"].Value.([]syncdomain.Message) + contents = append(contents, messages[0].Content) + } + assert.ElementsMatch(t, []string{"assigned", "block"}, contents, "a this assignment in a static method and a static block bind static clients") +} + +func TestTypeScriptDuplicateToolIDsGetOrdinals(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: []byte(` +import { generateText, tool } from "ai"; +const first = generateText({ model, tools: { search: tool({ description: "First", parameters: { type: "object" } }) } }); +const second = generateText({ model, tools: { search: tool({ description: "Second", parameters: { type: "object" } }) } }); +`)}}, true) + require.NoError(t, err) + tools := map[string]*adapter.Node{} + for _, item := range root.Fields["tools"].Items { + tools[item.Fields["id"].Value.(string)] = item + } + require.Len(t, tools, 2, "two inline tools of one name in one scope have two ids") + assert.Equal(t, "First", tools["search#1"].Fields["description"].Value) + assert.Equal(t, "Second", tools["search#2"].Fields["description"].Value) + assert.True(t, adapter.IsPositionalID(tools["search#1"])) +} + +func TestTypeScriptConstructorParameterPropertiesAreInstances(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: []byte(` +import OpenAI from "openai"; +class Bot { + constructor(private client = new OpenAI()) {} + run() { + return this.client.chat.completions.create({ model: "gpt", messages: [{ role: "user", content: "Hi" }] }); + } +} +`)}}, true) + require.NoError(t, err) + require.Len(t, root.Fields["prompts"].Items, 1, "a parameter property is a client of the instance") + assert.Equal(t, []syncdomain.Message{{Role: syncdomain.RoleUser, Content: "Hi"}}, root.Fields["prompts"].Items[0].Fields["messages"].Value) +} + +func TestTypeScriptAliasesOfImportsFollowTheirPosition(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{ + Primary: adapter.Document{Path: "src/main.ts", Data: []byte(` +import { Agent } from "@openai/agents"; +import { TEXT } from "./values"; +const early = new Agent({ instructions: prompt }); +const prompt = TEXT; +const late = new Agent({ instructions: prompt }); +`)}, + Dependencies: map[string]adapter.Document{ + "src/values.ts": {Path: "src/values.ts", Data: []byte("export const TEXT = \"Hello\";\n")}, + }, + }, true) + require.NoError(t, err) + agents := map[string]*adapter.Node{} + for _, agent := range root.Fields["agents"].Items { + agents[agent.Fields["id"].Value.(string)] = agent + } + assert.Equal(t, adapter.CapabilityReadOnly, agents["early"].Fields["instructions"].Capability, "an alias of an import is not in scope before its declaration") + assert.Equal(t, "Hello", agents["late"].Fields["instructions"].Value) +} + +func TestTypeScriptShorthandPropertiesDefaultExportsAndScopedMembers(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{ + Primary: adapter.Document{Path: "src/main.ts", Data: []byte(` +import { Agent } from "@openai/agents"; +import prompt from "./prompt"; +import * as values from "./values"; +const instructions = prompt; +const first = new Agent({ name: "first", instructions }); +function build() { + const values = { TEXT: "local" }; + return new Agent({ name: "second", instructions: values.TEXT }); +} +const third = new Agent({ name: "third", instructions: values.TEXT }); +`)}, + Dependencies: map[string]adapter.Document{ + "src/prompt.mts": {Path: "src/prompt.mts", Data: []byte(`export default "Help from default";`)}, + "src/values.ts": {Path: "src/values.ts", Data: []byte(`export const TEXT = "imported";`)}, + }, + }, true) + require.NoError(t, err) + agents := map[string]*adapter.Node{} + for _, agent := range root.Fields["agents"].Items { + agents[agent.Fields["id"].Value.(string)] = agent + } + assert.Equal(t, "Help from default", agents["first"].Fields["instructions"].Value, "a shorthand property resolves, through a default export in an .mts file") + assert.Equal(t, "local", agents["second"].Fields["instructions"].Value, "a member access uses the local namespace") + assert.Equal(t, "imported", agents["third"].Fields["instructions"].Value, "a member access uses the imported namespace") +} + +func TestTypeScriptVercelToolsFollowTheImportAlias(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: []byte(` +import { tool as aiTool } from "ai"; +const tools = { search: aiTool({ description: "Search", parameters: { type: "object" } }) }; +`)}}, true) + require.NoError(t, err) + require.Len(t, root.Fields["tools"].Items, 1) + assert.Equal(t, "search", root.Fields["tools"].Items[0].Fields["id"].Value) + assert.Equal(t, "Search", root.Fields["tools"].Items[0].Fields["description"].Value) +} + +func TestTypeScriptDynamicLocalShadowsAnImportAndNestedIDsAreDistinct(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{ + Primary: adapter.Document{Path: "src/main.ts", Data: []byte(` +import { Agent } from "@openai/agents"; +import { TEXT } from "./values"; +const TEXT = load(); +const team = [new Agent({ instructions: TEXT }), new Agent({ instructions: "static" })]; +`)}, + Dependencies: map[string]adapter.Document{ + "src/values.ts": {Path: "src/values.ts", Data: []byte(`export const TEXT = "imported";`)}, + }, + }, true) + require.NoError(t, err) + require.Len(t, root.Fields["agents"].Items, 2) + ids := []string{} + for _, agent := range root.Fields["agents"].Items { + ids = append(ids, agent.Fields["id"].Value.(string)) + } + assert.ElementsMatch(t, []string{"Agent", "Agent#2"}, ids) + for _, agent := range root.Fields["agents"].Items { + if agent.Fields["id"].Value == "Agent" { + assert.Equal(t, adapter.CapabilityReadOnly, agent.Fields["instructions"].Capability, "the dynamic local hides the import") + } + } + assert.Contains(t, typeScriptModulePaths("src/main.ts", "./lib"), "src/lib/index.tsx") + assert.Contains(t, typeScriptModulePaths("src/main.ts", "./lib.mjs"), "src/lib.mjs") +} + +func TestTypeScriptNamespaceFrameworkImportsAndNestedMembers(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{ + Primary: adapter.Document{Path: "src/main.ts", Data: []byte(` +import * as agents from "@openai/agents"; +import * as AI from "ai"; +import * as values from "./values"; +const support = new agents.Agent({ name: "support", instructions: values.CONFIG.instructions }); +const text = AI.generateText({ prompt: values.CONFIG.nested.prompt }); +`)}, + Dependencies: map[string]adapter.Document{ + "src/values.ts": {Path: "src/values.ts", Data: []byte(`export const CONFIG = { instructions: "Help", nested: { prompt: "Deep" } };`)}, + }, + }, true) + require.NoError(t, err) + require.Len(t, root.Fields["agents"].Items, 1) + assert.Equal(t, "Help", root.Fields["agents"].Items[0].Fields["instructions"].Value) + require.Len(t, root.Fields["prompts"].Items, 1) + assert.Equal(t, "Deep", root.Fields["prompts"].Items[0].Fields["prompt"].Value) +} + +func TestTypeScriptNestedPromptsAreNumberedAndNumbersAreParsed(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: []byte(` +import { PromptTemplate } from "@langchain/core/prompts"; +import { generateText } from "ai"; +const prompts = { a: PromptTemplate.fromTemplate("one"), b: PromptTemplate.fromTemplate("two") }; +const text = generateText({ prompt: "Hi", frequencyPenalty: -0.5, maxRetries: 1_000, limit: 0x10 }); +`)}}, true) + require.NoError(t, err) + ids := map[string]*adapter.Node{} + for _, prompt := range root.Fields["prompts"].Items { + ids[prompt.Fields["id"].Value.(string)] = prompt + } + assert.Equal(t, "one", ids["prompts#1"].Fields["template"].Value) + assert.Equal(t, "two", ids["prompts#2"].Fields["template"].Value) + assert.Equal(t, -0.5, ids["text"].Fields["frequencyPenalty"].Value) + assert.Equal(t, 1000.0, ids["text"].Fields["maxRetries"].Value) + assert.Equal(t, 16.0, ids["text"].Fields["limit"].Value) +} + +func TestTypeScriptIntegersBeyondExactFloatsAreReadOnly(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: []byte(` +import { generateText } from "ai"; +const EXACT = 9007199254740992; +const BIG = 9007199254740993; +const text = generateText({ + prompt: "Hi", exact: EXACT, big: 9007199254740993, huge: 99999999999999999999, + bigint: 9007199254740993n, negative: -9007199254740993, floating: 1e20, hex: 0xff, +}); +`)}}, true) + require.NoError(t, err) + constants := map[string]*adapter.Node{} + for _, constant := range root.Fields["constants"].Items { + constants[constant.Fields["id"].Value.(string)] = constant + } + assert.Equal(t, float64(1<<53), constants["EXACT"].Value, "the largest exact integer is a constant") + assert.Nil(t, constants["BIG"], "an integer without an exact float64 is not a literal constant") + require.Len(t, root.Fields["prompts"].Items, 1) + fields := root.Fields["prompts"].Items[0].Fields + assert.Equal(t, float64(1<<53), fields["exact"].Value) + for _, field := range []string{"big", "huge", "bigint", "negative"} { + assert.Equal(t, adapter.CapabilityReadOnly, fields[field].Capability, "%s has no exact float64", field) + assert.Contains(t, fields[field].Reason, "number is not representable", field) + } + assert.Equal(t, 1e20, fields["floating"].Value, "a float literal keeps its float value") + assert.Equal(t, 255.0, fields["hex"].Value) +} + +func TestTypeScriptDynamicLocalShadowsAnImportForLaterConstants(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{ + Primary: adapter.Document{Path: "src/main.ts", Data: []byte(` +import { Agent } from "@openai/agents"; +import { TEXT } from "./values"; +const TEXT = load(); +const CONFIG = { name: "support", instructions: TEXT }; +const support = new Agent(CONFIG); +`)}, + Dependencies: map[string]adapter.Document{ + "src/values.ts": {Path: "src/values.ts", Data: []byte(`export const TEXT = "imported";`)}, + }, + }, true) + require.NoError(t, err) + require.Len(t, root.Fields["agents"].Items, 1) + assert.Equal(t, adapter.CapabilityReadOnly, root.Fields["agents"].Items[0].Fields["instructions"].Capability, + "a later constant sees the dynamic local, not the import") +} + +func TestTypeScriptAssignedClientsAreTracked(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: []byte(` +import OpenAI from "openai"; +import Anthropic from "@anthropic-ai/sdk"; +let client; +client = new OpenAI(); +const first = client.chat.completions.create({ model: "gpt", messages: [{ role: "user", content: "one" }] }); +let shared = new OpenAI(); +shared = new Anthropic(); +const second = shared.messages.create({ model: "claude", messages: [{ role: "user", content: "two" }] }); +`)}}, true) + require.NoError(t, err) + ids := map[string]bool{} + for _, prompt := range root.Fields["prompts"].Items { + ids[prompt.Fields["id"].Value.(string)] = true + } + assert.True(t, ids["first"], "a client assigned after its declaration has a kind") + assert.False(t, ids["second"], "a client that two frameworks assign is ambiguous") +} + +func TestTypeScriptFrameworkTextIsNotTranslated(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: []byte(` +import { generateText } from "ai"; +import { PromptTemplate } from "@langchain/core/prompts"; +const text = generateText({ prompt: "Reply as JSON: {\"answer\": \"yes\"} with ${name}" }); +const chain = PromptTemplate.fromTemplate("Hello {name}"); +`)}}, true) + require.NoError(t, err) + prompts := map[string]*adapter.Node{} + for _, prompt := range root.Fields["prompts"].Items { + prompts[prompt.Fields["id"].Value.(string)] = prompt + } + plain := prompts["text"].Fields["prompt"] + assert.Equal(t, `Reply as JSON: {"answer": "yes"} with ${name}`, plain.Value, "the AI SDK sends the prompt as it is") + assert.Equal(t, adapter.CapabilityDirect, plain.Capability) + assert.Equal(t, "Hello {{name}}", prompts["chain"].Fields["template"].Value, "LangChain fills braces") +} + +func TestTypeScriptNamespacedPromptFactoriesAreCollected(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: []byte(` +import * as prompts from "@langchain/core/prompts"; +const chat = prompts.ChatPromptTemplate.fromMessages([["system", "Help {name}"]]); +const simple = prompts.PromptTemplate.fromTemplate("Summarize {topic}"); +`)}}, true) + require.NoError(t, err) + byID := map[string]*adapter.Node{} + for _, prompt := range root.Fields["prompts"].Items { + byID[prompt.Fields["id"].Value.(string)] = prompt + } + require.Contains(t, byID, "chat", "a factory reached through a namespace import is a prompt") + assert.Equal(t, []syncdomain.Message{{Role: syncdomain.RoleSystem, Content: "Help {{name}}"}}, byID["chat"].Fields["messages"].Value) + require.Contains(t, byID, "simple") + assert.Equal(t, "Summarize {{topic}}", byID["simple"].Fields["template"].Value) +} + +func TestTypeScriptNestedVercelToolsObjectIsCollected(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: []byte(` +import { tool } from "ai"; +function build() { + const tools = { search: tool({ description: "Search", parameters: { type: "object" } }) }; + return tools; +} +`)}}, true) + require.NoError(t, err) + require.Len(t, root.Fields["tools"].Items, 1) + assert.Equal(t, "build.search", root.Fields["tools"].Items[0].Fields["id"].Value, "the function scope names the tool") + assert.Equal(t, "Search", root.Fields["tools"].Items[0].Fields["description"].Value) +} + +func TestTypeScriptTemplateLiteralWritesKeepLineBreaks(t *testing.T) { + source := []byte("const prompt = `line one\nline two`;\n") + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: source}}, true) + require.NoError(t, err) + node, err := root.Resolve(`constants[id="prompt"]`) + require.NoError(t, err) + assert.Equal(t, "line one\nline two", node.Value) + encoded, err := node.Encode("first\nsecond\nthird") + require.NoError(t, err) + assert.Equal(t, "`first\nsecond\nthird`", string(encoded), "a template literal keeps real line breaks") +} + +func TestTypeScriptWrappedMemberAccessResolves(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: []byte(` +import { Agent } from "@openai/agents"; +const CONFIG = { prompt: "Help", nested: { prompt: "Deep" } }; +const first = new Agent({ instructions: CONFIG!.prompt }); +const second = new Agent({ instructions: (CONFIG as const).nested!.prompt }); +`)}}, true) + require.NoError(t, err) + agents := map[string]*adapter.Node{} + for _, agent := range root.Fields["agents"].Items { + agents[agent.Fields["id"].Value.(string)] = agent + } + assert.Equal(t, "Help", agents["first"].Fields["instructions"].Value, "a non-null assertion on a segment is ignored") + assert.Equal(t, "Deep", agents["second"].Fields["instructions"].Value, "a type wrapper on a segment is ignored") +} + +func TestTypeScriptNestedPromptIsCollectedOnce(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: []byte(` +import { PromptTemplate } from "@langchain/core/prompts"; +const create = () => { + const prompt = PromptTemplate.fromTemplate("Hi {name}"); + return prompt; +}; +`)}}, true) + require.NoError(t, err) + require.Len(t, root.Fields["prompts"].Items, 1, "the inner declaration names the prompt once") + prompt := root.Fields["prompts"].Items[0] + assert.Equal(t, "create.prompt", prompt.Fields["id"].Value) + assert.Equal(t, "Hi {{name}}", prompt.Fields["template"].Value) +} + +func TestTypeScriptMutatedImportIsReadOnly(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{ + Primary: adapter.Document{Path: "src/main.ts", Data: []byte(` +import { Agent } from "@openai/agents"; +import { CONFIG, OTHER } from "./values"; +CONFIG.prompt = "changed"; +const first = new Agent({ instructions: CONFIG.prompt }); +const second = new Agent({ instructions: OTHER }); +`)}, + Dependencies: map[string]adapter.Document{ + "src/values.ts": {Path: "src/values.ts", Data: []byte(`export const CONFIG = { prompt: "Help" }; +export const OTHER = "Keep"; +`)}, + }, + }, true) + require.NoError(t, err) + agents := map[string]*adapter.Node{} + for _, agent := range root.Fields["agents"].Items { + agents[agent.Fields["id"].Value.(string)] = agent + } + mutated := agents["first"].Fields["instructions"] + assert.Equal(t, adapter.CapabilityReadOnly, mutated.Capability, "an import that this file mutates is read-only") + assert.Equal(t, "mutated constants are read-only", mutated.Reason) + assert.Equal(t, adapter.CapabilityDirect, agents["second"].Fields["instructions"].Capability) +} + +func TestTypeScriptMCPToolOverloads(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: []byte(` +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +const server = new McpServer({ name: "orders", version: "1" }); +server.tool("described", "Look up an order.", { type: "object" }, handler); +server.tool("bare", { type: "object" }, handler); +server.tool("plain", handler); +`)}}, true) + require.NoError(t, err) + tools := map[string]*adapter.Node{} + for _, tool := range root.Fields["tools"].Items { + tools[tool.Fields["id"].Value.(string)] = tool + } + require.Len(t, tools, 3) + assert.Equal(t, "Look up an order.", tools["described"].Fields["description"].Value) + assert.Equal(t, map[string]any{"type": "object"}, tools["described"].Fields["schema"].Value) + assert.Nil(t, tools["bare"].Fields["description"], "a schema after the name is not a description") + assert.Equal(t, map[string]any{"type": "object"}, tools["bare"].Fields["schema"].Value) + assert.Nil(t, tools["plain"].Fields["schema"], "the handler is not a schema") +} + +func TestTypeScriptWrappedCallablesMatchFrameworks(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: []byte(` +import OpenAI from "openai"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +const client = new OpenAI(); +const server = new McpServer({ name: "orders", version: "1" }); +const reply = client!.chat.completions.create({ model: "gpt-5", messages: [{ role: "user", content: "Hi" }] }); +(server as McpServer).registerTool("wrapped", { description: "Wrapped.", inputSchema: { type: "object" } }, handler); +server!.tool("asserted", "Asserted.", { type: "object" }, handler); +`)}}, true) + require.NoError(t, err) + require.Len(t, root.Fields["prompts"].Items, 1, "a wrapped receiver still names its client") + assert.Equal(t, "reply", root.Fields["prompts"].Items[0].Fields["id"].Value) + tools := map[string]*adapter.Node{} + for _, tool := range root.Fields["tools"].Items { + tools[tool.Fields["id"].Value.(string)] = tool + } + assert.Equal(t, "Wrapped.", tools["wrapped"].Fields["description"].Value) + assert.Equal(t, "Asserted.", tools["asserted"].Fields["description"].Value) +} + +func TestTypeScriptShorthandMessagesAreAMessageList(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: []byte(` +import { generateText } from "ai"; +const messages = [{ role: "user", content: "Hi" }]; +const reply = generateText({ model, messages }); +`)}}, true) + require.NoError(t, err) + require.Len(t, root.Fields["prompts"].Items, 1) + list := root.Fields["prompts"].Items[0].Fields["messages"] + assert.Equal(t, []syncdomain.Message{{Role: syncdomain.RoleUser, Content: "Hi"}}, list.Value, "a shorthand constant converts as a literal array does") + require.Len(t, list.Items, 1) + assert.Equal(t, "Hi", list.Items[0].Fields["content"].Value) +} + +func TestTypeScriptReassignedConstantsAreReadOnlyWithDistinctIDs(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: []byte(` +let NAME = "one"; +NAME = "two"; +`)}}, true) + require.NoError(t, err) + constants := map[string]*adapter.Node{} + for _, constant := range root.Fields["constants"].Items { + constants[constant.Fields["id"].Value.(string)] = constant + } + require.Contains(t, constants, "NAME") + for id, constant := range constants { + assert.Equal(t, adapter.CapabilityReadOnly, constant.Capability, id) + } +} + +func TestTypeScriptAssignedFunctionPromptsAreCollectedOnce(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: []byte(` +import { PromptTemplate } from "@langchain/core/prompts"; +const create = () => { + return PromptTemplate.fromTemplate("Hi {name}"); +}; +const build = () => PromptTemplate.fromTemplate("Yo {name}"); +`)}}, true) + require.NoError(t, err) + ids := []string{} + for _, prompt := range root.Fields["prompts"].Items { + ids = append(ids, prompt.Fields["id"].Value.(string)) + } + assert.ElementsMatch(t, []string{"create", "build"}, ids, "a prompt returned by an assigned function is named once, by the variable") +} + +func TestTypeScriptWrappedMutationsFreezeImports(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{ + Primary: adapter.Document{Path: "src/main.ts", Data: []byte(` +import { Agent } from "@openai/agents"; +import { CONFIG, LIST } from "./values"; +CONFIG!.prompt = "changed"; +(LIST as string[]).push("more"); +const agent = new Agent({ instructions: CONFIG.prompt }); +const copy = LIST; +`)}, + Dependencies: map[string]adapter.Document{ + "src/values.ts": {Path: "src/values.ts", Data: []byte(`export const CONFIG = { prompt: "Help" }; +export const LIST = ["one"]; +`)}, + }, + }, true) + require.NoError(t, err) + require.Len(t, root.Fields["agents"].Items, 1) + assert.Equal(t, adapter.CapabilityReadOnly, root.Fields["agents"].Items[0].Fields["instructions"].Capability, + "an assignment through a non-null assertion mutates the import") + copied, err := root.Resolve(`constants[id="copy"]`) + require.NoError(t, err) + assert.Equal(t, adapter.CapabilityReadOnly, copied.Capability, "a push through a type wrapper mutates the import") +} + +func TestTypeScriptWrappedMCPConstructorIsAServer(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: []byte(` +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +const server = new (McpServer as any)({ name: "orders", version: "1" }); +server.registerTool("wrapped", { description: "Wrapped.", inputSchema: { type: "object" } }, handler); +server.tool("plain", "Plain.", { type: "object" }, handler); +`)}}, true) + require.NoError(t, err) + tools := map[string]*adapter.Node{} + for _, tool := range root.Fields["tools"].Items { + tools[tool.Fields["id"].Value.(string)] = tool + } + require.Len(t, tools, 2, "a wrapped constructor still records the server") + assert.Equal(t, "Wrapped.", tools["wrapped"].Fields["description"].Value) + assert.Equal(t, "Plain.", tools["plain"].Fields["description"].Value) +} + +func TestTypeScriptClassFieldsAndThisPropertiesAreInstances(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: []byte(` +import OpenAI from "openai"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +class Assistant { + client = new OpenAI(); + server: McpServer; + constructor() { + this.server = new McpServer({ name: "orders", version: "1" }); + } + ask() { + return this.client.chat.completions.create({ model: "gpt-5", messages: [{ role: "user", content: "Hi" }] }); + } + register() { + this.server.tool("lookup", "Look up.", { type: "object" }, handler); + this.server.registerTool("registered", { description: "Registered.", inputSchema: { type: "object" } }, handler); + } +} +`)}}, true) + require.NoError(t, err) + require.Len(t, root.Fields["prompts"].Items, 1, "this.client names the class field") + assert.Equal(t, "Assistant.ask.this.client.chat.completions.create", root.Fields["prompts"].Items[0].Fields["id"].Value) + tools := map[string]*adapter.Node{} + for _, tool := range root.Fields["tools"].Items { + tools[tool.Fields["id"].Value.(string)] = tool + } + require.Len(t, tools, 2, "this.server names the property set in the constructor") + assert.Equal(t, "Look up.", tools["lookup"].Fields["description"].Value) + assert.Equal(t, "Registered.", tools["registered"].Fields["description"].Value) +} + +func TestTypeScriptClassFieldsNameTheirResources(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: []byte(` +import { tool } from "ai"; +import { PromptTemplate } from "@langchain/core/prompts"; +class Assistant { + tools = { search: tool({ description: "Search", parameters: { type: "object" } }) }; + prompt = PromptTemplate.fromTemplate("Hi {name}"); +} +`)}}, true) + require.NoError(t, err) + require.Len(t, root.Fields["tools"].Items, 1, "a tools field of a class is collected") + assert.Equal(t, "Assistant.search", root.Fields["tools"].Items[0].Fields["id"].Value) + require.Len(t, root.Fields["prompts"].Items, 1, "a prompt field is named once") + assert.Equal(t, "Assistant.prompt", root.Fields["prompts"].Items[0].Fields["id"].Value) +} + +func TestTypeScriptImportStaysVisibleBeforeALaterShadowingDeclaration(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{ + Primary: adapter.Document{Path: "src/main.ts", Data: []byte(` +import { Agent } from "@openai/agents"; +import { TEXT } from "./values"; +const first = new Agent({ instructions: TEXT }); +const TEXT = "Local"; +const second = new Agent({ instructions: TEXT }); +`)}, + Dependencies: map[string]adapter.Document{ + "src/values.ts": {Path: "src/values.ts", Data: []byte(`export const TEXT = "Imported";`)}, + }, + }, true) + require.NoError(t, err) + agents := map[string]*adapter.Node{} + for _, agent := range root.Fields["agents"].Items { + agents[agent.Fields["id"].Value.(string)] = agent + } + assert.Equal(t, "Imported", agents["first"].Fields["instructions"].Value, "a use before the declaration sees the import") + assert.Equal(t, "Local", agents["second"].Fields["instructions"].Value) +} + +func TestTypeScriptMethodsDoNotSeeClassFieldsForBareNames(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: []byte(` +import OpenAI from "openai"; +import Anthropic from "@anthropic-ai/sdk"; +const client = new Anthropic(); +class Assistant { + client = new OpenAI(); + ask() { + const first = client.messages.create({ model: "claude", messages: [{ role: "user", content: "One" }] }); + const second = this.client.chat.completions.create({ model: "gpt-5", messages: [{ role: "user", content: "Two" }] }); + return [first, second]; + } +} +`)}}, true) + require.NoError(t, err) + prompts := map[string]*adapter.Node{} + for _, prompt := range root.Fields["prompts"].Items { + prompts[prompt.Fields["id"].Value.(string)] = prompt + } + require.Len(t, prompts, 2, "the bare name is the module client and this.client is the field") + assert.Equal(t, "claude", prompts["Assistant.ask.first"].Fields["model"].Value) + assert.Equal(t, "gpt-5", prompts["Assistant.ask.second"].Fields["model"].Value) +} + +func TestJavaScriptCommonJSRequiresAreFrameworkImports(t *testing.T) { + for name, source := range map[string]string{ + "destructured": "const { Agent } = require(\"@openai/agents\");\nconst support = new Agent({ name: \"support\", instructions: \"Help.\" });\n", + "renamed": "const { Agent: Bot } = require(\"@openai/agents\");\nconst support = new Bot({ name: \"support\", instructions: \"Help.\" });\n", + "default": "const OpenAI = require(\"openai\");\nconst client = new OpenAI();\nconst reply = client.chat.completions.create({ model: \"gpt-5\", messages: [{ role: \"system\", content: \"Help.\" }] });\n", + } { + t.Run(name, func(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "src/main.js", Data: []byte(source)}}, false) + require.NoError(t, err) + assert.Equal(t, 1, len(root.Fields["agents"].Items)+len(root.Fields["prompts"].Items), "a require binds the framework symbols as an import does") + }) + } + + root, err := parseTypeScriptBundle(adapter.SourceBundle{ + Primary: adapter.Document{Path: "src/main.js", Data: []byte("const { Agent } = require(\"@openai/agents\");\nconst { TEXT } = require(\"./values\");\nconst support = new Agent({ instructions: TEXT });\n")}, + Dependencies: map[string]adapter.Document{"src/values.js": {Path: "src/values.js", Data: []byte("export const TEXT = \"Imported\";\n")}}, + }, false) + require.NoError(t, err) + require.Len(t, root.Fields["agents"].Items, 1) + assert.Equal(t, "Imported", root.Fields["agents"].Items[0].Fields["instructions"].Value, "a required repository module resolves its constants") +} + +func TestTypeScriptLocalBindingsShadowOuterInstances(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: []byte(` +import OpenAI from "openai"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +const client = new OpenAI(); +const server = new McpServer({ name: "orders", version: "1" }); +function test() { + const client = mockClient(); + const server = mockServer(); + server.registerTool("fake", { description: "Fake.", inputSchema: { type: "object" } }, handler); + server.tool("other", "Other.", { type: "object" }, handler); + return client.chat.completions.create({ model: "gpt-5", messages: [{ role: "user", content: "Hi" }] }); +} +`)}}, true) + require.NoError(t, err) + assert.Empty(t, root.Fields["tools"].Items, "a local mock named server is not the MCP server") + assert.Empty(t, root.Fields["prompts"].Items, "a local mock named client is not the OpenAI client") +} + +func TestTypeScriptClassFieldsAreConstants(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: []byte(` +class Assistant { + instructions = "Ask for an order ID."; + limits = { retries: 3 }; +} +`)}}, true) + require.NoError(t, err) + instructions, err := root.Resolve(`constants[id="Assistant.instructions"]`) + require.NoError(t, err) + assert.Equal(t, "Ask for an order ID.", instructions.Value) + assert.Equal(t, adapter.CapabilityDirect, instructions.Capability) + retries, err := root.Resolve(`constants[id="Assistant.limits"].retries`) + require.NoError(t, err) + assert.Equal(t, 3.0, retries.Value) +} + +func TestTypeScriptMutationsThroughThisFreezeClassFields(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: []byte(` +class Assistant { + items = ["a"]; + limit = 3; + keep = "same"; + add() { + this.items.push("b"); + this.limit = 5; + } +} +`)}}, true) + require.NoError(t, err) + capability := func(id string) adapter.Capability { + node, err := root.Resolve(`constants[id="` + id + `"]`) + require.NoError(t, err) + return node.Capability + } + assert.Equal(t, adapter.CapabilityReadOnly, capability("Assistant.items"), "a push through this mutates the field") + assert.Equal(t, adapter.CapabilityReadOnly, capability("Assistant.limit"), "an assignment through this mutates the field") + assert.Equal(t, adapter.CapabilityDirect, capability("Assistant.keep")) +} + +func TestTypeScriptSubscriptsResolveStatically(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: []byte(` +import { Agent } from "@openai/agents"; +const CONFIG = { prompt: "Help" }; +const LIST = ["one", "two"]; +const first = new Agent({ instructions: CONFIG["prompt"] }); +const second = new Agent({ instructions: LIST[1] }); +`)}}, true) + require.NoError(t, err) + agents := map[string]*adapter.Node{} + for _, agent := range root.Fields["agents"].Items { + agents[agent.Fields["id"].Value.(string)] = agent + } + assert.Equal(t, "Help", agents["first"].Fields["instructions"].Value, "a string subscript reads the object entry") + assert.Equal(t, "two", agents["second"].Fields["instructions"].Value, "a number subscript reads the array item") +} + +func TestTypeScriptThisPropertyMocksShadowClassFieldInstances(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: []byte(` +import OpenAI from "openai"; +class Assistant { + client = new OpenAI(); + constructor() { + this.client = mockClient(); + } + ask() { + return this.client.chat.completions.create({ model: "gpt-5", messages: [{ role: "user", content: "Hi" }] }); + } +} +`)}}, true) + require.NoError(t, err) + assert.Empty(t, root.Fields["prompts"].Items, "a property set to a mock hides the field instance") +} + +func TestTypeScriptThisFieldsResolveToClassConstants(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: []byte(` +import { Agent } from "@openai/agents"; +class Assistant { + instructions = "Ask for an order ID."; + build() { + return new Agent({ instructions: this.instructions }); + } +} +`)}}, true) + require.NoError(t, err) + require.Len(t, root.Fields["agents"].Items, 1) + assert.Equal(t, "Ask for an order ID.", root.Fields["agents"].Items[0].Fields["instructions"].Value, "this.field reads the class field constant") +} + +func TestTypeScriptUpdateExpressionsFreezeTheirTargets(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: []byte(` +let count = 1; +count++; +const stable = 2; +class Assistant { + limit = 3; + tick() { + --this.limit; + } +} +`)}}, true) + require.NoError(t, err) + capability := func(id string) adapter.Capability { + node, err := root.Resolve(`constants[id="` + id + `"]`) + require.NoError(t, err) + return node.Capability + } + assert.Equal(t, adapter.CapabilityReadOnly, capability("count"), "an increment mutates the constant") + assert.Equal(t, adapter.CapabilityReadOnly, capability("Assistant.limit"), "a decrement through this mutates the field") + assert.Equal(t, adapter.CapabilityDirect, capability("stable")) +} + +func TestTypeScriptPropertyAfterSubscriptResolves(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: []byte(` +import { Agent } from "@openai/agents"; +const CONFIG = { nested: { prompt: "Help" } }; +const LIST = [{ prompt: "First" }]; +const first = new Agent({ instructions: CONFIG["nested"].prompt }); +const second = new Agent({ instructions: LIST[0].prompt }); +`)}}, true) + require.NoError(t, err) + agents := map[string]*adapter.Node{} + for _, agent := range root.Fields["agents"].Items { + agents[agent.Fields["id"].Value.(string)] = agent + } + assert.Equal(t, "Help", agents["first"].Fields["instructions"].Value, "a property after a string subscript resolves") + assert.Equal(t, "First", agents["second"].Fields["instructions"].Value, "a property after a number subscript resolves") +} + +func TestTypeScriptWrappedRegisterToolCalleesAreCollected(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: []byte(` +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +const server = new McpServer({ name: "orders", version: "1" }); +server.registerTool!("asserted", { description: "Asserted.", inputSchema: { type: "object" } }, handler); +(server.registerTool)("wrapped", { description: "Wrapped.", inputSchema: { type: "object" } }, handler); +`)}}, true) + require.NoError(t, err) + tools := map[string]*adapter.Node{} + for _, tool := range root.Fields["tools"].Items { + tools[tool.Fields["id"].Value.(string)] = tool + } + require.Len(t, tools, 2, "a wrapper on the callee does not hide the call") + assert.Equal(t, "Asserted.", tools["asserted"].Fields["description"].Value) + assert.Equal(t, "Wrapped.", tools["wrapped"].Fields["description"].Value) +} + +func TestTypeScriptImportRequireBindsTheDefaultExport(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: []byte(` +import OpenAI = require("openai"); +const client = new OpenAI(); +const reply = client.chat.completions.create({ model: "gpt-5", messages: [{ role: "user", content: "Hi" }] }); +`)}}, true) + require.NoError(t, err) + require.Len(t, root.Fields["prompts"].Items, 1, "the TypeScript CommonJS import binds the client") + assert.Equal(t, "reply", root.Fields["prompts"].Items[0].Fields["id"].Value) +} + +func TestJavaScriptNestedRequiresDoNotShadowFileImports(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "src/main.js", Data: []byte(` +const { Agent } = require("@openai/agents"); +function helper() { + const { Agent } = require("./local"); + return new Agent({ instructions: "local" }); +} +const support = new Agent({ instructions: "Help" }); +`)}}, false) + require.NoError(t, err) + ids := []string{} + for _, agent := range root.Fields["agents"].Items { + ids = append(ids, agent.Fields["id"].Value.(string)) + } + assert.Equal(t, []string{"support"}, ids, "a require inside a function does not change the imports of the file") +} + +func TestTypeScriptDefaultExportClauseResolves(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{ + Primary: adapter.Document{Path: "src/main.ts", Data: []byte(` +import { Agent } from "@openai/agents"; +import text from "./values"; +const support = new Agent({ instructions: text }); +`)}, + Dependencies: map[string]adapter.Document{ + "src/values.ts": {Path: "src/values.ts", Data: []byte("const TEXT = \"Help\";\nexport { TEXT as default };\n")}, + }, + }, true) + require.NoError(t, err) + require.Len(t, root.Fields["agents"].Items, 1) + assert.Equal(t, "Help", root.Fields["agents"].Items[0].Fields["instructions"].Value, "an export clause with a default alias is the default export") +} + +func TestTypeScriptDefaultExportClauseBeforeTheDeclarationResolves(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{ + Primary: adapter.Document{Path: "src/main.ts", Data: []byte(` +import { Agent } from "@openai/agents"; +import text from "./values"; +const support = new Agent({ instructions: text }); +`)}, + Dependencies: map[string]adapter.Document{ + "src/values.ts": {Path: "src/values.ts", Data: []byte("export { TEXT as default };\nconst TEXT = \"Help\";\n")}, + }, + }, true) + require.NoError(t, err) + require.Len(t, root.Fields["agents"].Items, 1) + instructions := root.Fields["agents"].Items[0].Fields["instructions"] + assert.Equal(t, "Help", instructions.Value, "the export clause names the constant that follows it") + assert.Equal(t, adapter.CapabilityDirect, instructions.Capability) +} + +func TestJavaScriptBlockDeclarationsHideARequireOfTheFunction(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "src/main.js", Data: []byte(` +function helper(flag) { + const { Agent } = require("@openai/agents"); + if (flag) { + const Agent = makeLocalAgent(); + return new Agent({ instructions: "local" }); + } + return new Agent({ instructions: "Help" }); +} +`)}}, false) + require.NoError(t, err) + require.Len(t, root.Fields["agents"].Items, 1, "the block declaration hides the require of the function inside the block") + assert.Equal(t, "Help", root.Fields["agents"].Items[0].Fields["instructions"].Value) +} + +func TestTypeScriptTypeOnlyExportsAreNotImportable(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{ + Primary: adapter.Document{Path: "src/main.ts", Data: []byte(` +import { Agent } from "@openai/agents"; +import { TEXT, OTHER, PUBLIC } from "./values"; +const erased = new Agent({ instructions: TEXT }); +const inline = new Agent({ instructions: OTHER }); +const shown = new Agent({ instructions: PUBLIC }); +`)}, + Dependencies: map[string]adapter.Document{ + "src/values.ts": {Path: "src/values.ts", Data: []byte(`const TEXT = "erased"; +export type { TEXT }; +const OTHER = "erased too"; +export { type OTHER, PUBLIC }; +const PUBLIC = "shown"; +`)}, + }, + }, true) + require.NoError(t, err) + agents := map[string]*adapter.Node{} + for _, agent := range root.Fields["agents"].Items { + agents[agent.Fields["id"].Value.(string)] = agent + } + assert.Equal(t, adapter.CapabilityReadOnly, agents["erased"].Fields["instructions"].Capability, "a type-only export clause exports no value") + assert.Equal(t, adapter.CapabilityReadOnly, agents["inline"].Fields["instructions"].Capability, "a type-only specifier exports no value") + assert.Equal(t, "shown", agents["shown"].Fields["instructions"].Value, "the value specifier of the same clause is importable") +} + +func TestTypeScriptCollectionsOfMutatedImportsAreReadOnly(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{ + Primary: adapter.Document{Path: "src/main.ts", Data: []byte(` +import { ITEMS, TEXT } from "./values"; +ITEMS.push("b"); +const WRAPPED = [ITEMS]; +const MAPPED = { items: ITEMS }; +const PLAIN = [TEXT]; +`)}, + Dependencies: map[string]adapter.Document{ + "src/values.ts": {Path: "src/values.ts", Data: []byte("export const ITEMS = [\"a\"];\nexport const TEXT = \"Hello\";\n")}, + }, + }, true) + require.NoError(t, err) + constants := map[string]*adapter.Node{} + for _, constant := range root.Fields["constants"].Items { + constants[constant.Fields["id"].Value.(string)] = constant + } + assert.Equal(t, adapter.CapabilityReadOnly, constants["WRAPPED"].Capability, "an array that holds a mutated import is read-only") + assert.Equal(t, adapter.CapabilityReadOnly, constants["MAPPED"].Capability, "an object that holds a mutated import is read-only") + assert.Equal(t, adapter.CapabilityDirect, constants["PLAIN"].Capability, "an array of a plain import stays writable") +} + +func TestTypeScriptReExportsAreNotLocalBindings(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{ + Primary: adapter.Document{Path: "src/main.ts", Data: []byte(` +import { Agent } from "@openai/agents"; +import fallback, { TEXT, PUBLIC } from "./values"; +const forwarded = new Agent({ instructions: TEXT }); +const defaulted = new Agent({ instructions: fallback }); +const shown = new Agent({ instructions: PUBLIC }); +`)}, + Dependencies: map[string]adapter.Document{ + "src/values.ts": {Path: "src/values.ts", Data: []byte(`const TEXT = "local"; +export { TEXT } from "./other"; +export { default } from "./other"; +export const PUBLIC = "shown"; +`)}, + }, + }, true) + require.NoError(t, err) + agents := map[string]*adapter.Node{} + for _, agent := range root.Fields["agents"].Items { + agents[agent.Fields["id"].Value.(string)] = agent + } + assert.Equal(t, adapter.CapabilityReadOnly, agents["forwarded"].Fields["instructions"].Capability, "a re-export is not the local constant of the same name") + assert.NotEqual(t, "local", agents["forwarded"].Fields["instructions"].Value) + assert.Equal(t, adapter.CapabilityReadOnly, agents["defaulted"].Fields["instructions"].Capability, "a re-exported default is a value of another module") + assert.Equal(t, "shown", agents["shown"].Fields["instructions"].Value) +} + +func TestTypeScriptInlineVercelToolsAreCollected(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: []byte(` +import { generateText, tool } from "ai"; +const reply = generateText({ model, tools: { search: tool({ description: "Search", parameters: { type: "object" } }) } }); +function build() { + return generateText({ model, tools: { lookup: tool({ description: "Look up", parameters: { type: "object" } }) } }); +} +`)}}, true) + require.NoError(t, err) + tools := map[string]*adapter.Node{} + for _, item := range root.Fields["tools"].Items { + tools[item.Fields["id"].Value.(string)] = item + } + require.Len(t, tools, 2, "inline tools objects are collected") + assert.Equal(t, "Search", tools["search"].Fields["description"].Value) + assert.Equal(t, "Look up", tools["build.lookup"].Fields["description"].Value, "the enclosing function names an inline tool") +} + +func TestTypeScriptWrappedThisAssignmentsNameTheProperty(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: []byte(` +import OpenAI from "openai"; +class Assistant { + client!: OpenAI; + constructor() { + this!.client = new OpenAI(); + } + ask() { + return this.client.chat.completions.create({ model: "gpt-5", messages: [{ role: "user", content: "Hi" }] }); + } +} +`)}}, true) + require.NoError(t, err) + require.Len(t, root.Fields["prompts"].Items, 1, "a wrapped this assignment binds the same property") +} + +func TestTypeScriptLangChainMessageConstructorsAreMessages(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: []byte(` +import { ChatPromptTemplate } from "@langchain/core/prompts"; +import { SystemMessage, HumanMessage } from "@langchain/core/messages"; +const chat = ChatPromptTemplate.fromMessages([new SystemMessage("Help {name}"), new HumanMessage({ content: "Hi" })]); +`)}}, true) + require.NoError(t, err) + require.Len(t, root.Fields["prompts"].Items, 1) + messages := root.Fields["prompts"].Items[0].Fields["messages"] + assert.Equal(t, []syncdomain.Message{ + {Role: syncdomain.RoleSystem, Content: "Help {{name}}"}, + {Role: syncdomain.RoleUser, Content: "Hi"}, + }, messages.Value, "the message classes carry their role and content") + assert.Equal(t, adapter.CapabilityDirect, messages.Capability) +} + +func TestTypeScriptFunctionsSeeLaterModuleConstants(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: []byte(` +import { Agent } from "@openai/agents"; +function build() { + return new Agent({ instructions: SYSTEM }); +} +const SYSTEM = "Help"; +`)}}, true) + require.NoError(t, err) + require.Len(t, root.Fields["agents"].Items, 1) + assert.Equal(t, "Help", root.Fields["agents"].Items[0].Fields["instructions"].Value, "a function body runs after the module loads") +} + +func TestTypeScriptBlockBindingsHideImportsInTheirBlockOnly(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: []byte(` +import { tool } from "ai"; +function build(items: string[]) { + const tools = { search: tool({ description: "Search", parameters: { type: "object" } }) }; + for (const tool of items) { + console.log(tool); + } + return tools; +} +`)}}, true) + require.NoError(t, err) + require.Len(t, root.Fields["tools"].Items, 1, "a loop binding hides the import inside the loop only") + assert.Equal(t, "build.search", root.Fields["tools"].Items[0].Fields["id"].Value) +} + +func TestTypeScriptParametersHideOuterBindings(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: []byte(` +import { Agent } from "@openai/agents"; +import { generateText, tool } from "ai"; +const instructions = "Module"; +function build(instructions: string) { + return new Agent({ instructions }); +} +function wrap(tool: (options: object) => object) { + return generateText({ model, tools: { search: tool({ description: "Not ours", parameters: {} }) } }); +} +const plain = new Agent({ instructions }); +`)}}, true) + require.NoError(t, err) + agents := map[string]*adapter.Node{} + for _, agent := range root.Fields["agents"].Items { + agents[agent.Fields["id"].Value.(string)] = agent + } + assert.NotEqual(t, adapter.CapabilityDirect, agents["build.Agent"].Fields["instructions"].Capability, "a parameter hides the module constant") + assert.Equal(t, "Module", agents["plain"].Fields["instructions"].Value) + assert.Empty(t, root.Fields["tools"].Items, "a parameter named tool hides the import") +} + +func TestTypeScriptSwitchBindingsHideImportsInTheSwitchOnly(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: []byte(` +import { tool } from "ai"; +function build(mode: number) { + switch (mode) { + case 1: { + const tool = "other"; + console.log(tool); + break; + } + } + const tools = { search: tool({ description: "Search", parameters: { type: "object" } }) }; + return tools; +} +`)}}, true) + require.NoError(t, err) + require.Len(t, root.Fields["tools"].Items, 1, "a switch binding hides the import inside the switch only") +} + +func TestTypeScriptUserIDsWithAHashAreNotPositional(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: []byte(` +import { Agent } from "@openai/agents"; +const team = [new Agent({ name: "triage#1", instructions: "Help" })]; +`)}}, true) + require.NoError(t, err) + agent, err := root.Resolve(`agents[id="triage#1"]`) + require.NoError(t, err) + assert.False(t, adapter.IsPositionalID(agent), "a name is an identity whatever characters it has") +} + +func TestJavaScriptFunctionParametersHideImportsInTheFunctionOnly(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "src/main.js", Data: []byte(` +import { tool } from "ai"; +const handler = function (tool) { return tool; }; +function* steps(tool) { yield tool; } +const tools = { search: tool({ description: "Search", parameters: { type: "object" } }) }; +`)}}, false) + require.NoError(t, err) + require.Len(t, root.Fields["tools"].Items, 1, "a parameter of a function expression hides the import in that function only") +} + +func TestTypeScriptLocalFunctionsCatchParametersAndModuleBlocksHideImports(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: []byte(` +import { Agent } from "@openai/agents"; +import { tool } from "ai"; +const SYSTEM = "Help"; +function build() { + function Agent(options) { return options; } + return new Agent({ instructions: "local function" }); +} +function handle() { + try { risky(); } catch (tool) { tool({ description: "Not ours", parameters: {} }); } + const tools = { search: tool({ description: "Ours", parameters: { type: "object" } }) }; + return tools; +} +function nested() { + class Agent {} + const SYSTEM = () => "dynamic"; + return new Agent({ instructions: SYSTEM }); +} +if (flag) { + const Agent = makeLocal(); + const blocked = new Agent({ instructions: "module block" }); +} +const plain = new Agent({ instructions: SYSTEM }); +`)}}, true) + require.NoError(t, err) + ids := []string{} + for _, agent := range root.Fields["agents"].Items { + ids = append(ids, agent.Fields["id"].Value.(string)) + } + assert.Equal(t, []string{"plain"}, ids, "a local function, a local class, and a block declaration at module level hide the import") + assert.Equal(t, "Help", root.Fields["agents"].Items[0].Fields["instructions"].Value) + require.Len(t, root.Fields["tools"].Items, 1, "a catch parameter hides the import in the catch clause only") + assert.Equal(t, "Ours", root.Fields["tools"].Items[0].Fields["description"].Value) +} + +func TestJavaScriptVarHidesImportsInTheWholeFunction(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "src/main.js", Data: []byte(` +import { tool } from "ai"; +function build(flag) { + if (flag) { + var tool = makeLocal(); + } + const tools = { search: tool({ description: "Not ours", parameters: {} }) }; + return tools; +} +const tools = { lookup: tool({ description: "Ours", parameters: { type: "object" } }) }; +`)}}, false) + require.NoError(t, err) + require.Len(t, root.Fields["tools"].Items, 1, "a var in a branch hides the import in the whole function") + assert.Equal(t, "Ours", root.Fields["tools"].Items[0].Fields["description"].Value) +} + +func TestJavaScriptVarRequiresHoistToTheFunction(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "src/main.js", Data: []byte(` +function build(flag) { + if (flag) { + var { Agent } = require("@openai/agents"); + } + return new Agent({ instructions: "hoisted" }); +} +`)}}, false) + require.NoError(t, err) + require.Len(t, root.Fields["agents"].Items, 1, "a var require is visible in the whole function") + assert.Equal(t, "hoisted", root.Fields["agents"].Items[0].Fields["instructions"].Value) +} + +func TestTypeScriptInstanceFieldsSeeLaterConstants(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: []byte(` +import { Agent } from "@openai/agents"; +class Bot { + agent = new Agent({ instructions: SYSTEM }); + static label = LATER; +} +const SYSTEM = "Help"; +const LATER = "late"; +`)}}, true) + require.NoError(t, err) + require.Len(t, root.Fields["agents"].Items, 1) + assert.Equal(t, "Help", root.Fields["agents"].Items[0].Fields["instructions"].Value, "an instance field runs in the constructor, after the module loads") + constants := map[string]*adapter.Node{} + for _, constant := range root.Fields["constants"].Items { + constants[constant.Fields["id"].Value.(string)] = constant + } + assert.Nil(t, constants["Bot.label"], "a static field runs with the class, before the later constant") +} + +func TestTypeScriptBlockConstantsBindInTheirBlockOnly(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: []byte(` +import { Agent } from "@openai/agents"; +function build(flag: boolean) { + const SYSTEM = "outer"; + if (flag) { + const SYSTEM = "inner"; + return new Agent({ instructions: SYSTEM }); + } + return new Agent({ instructions: SYSTEM }); +} +function later(flag: boolean) { + const first = new Agent({ instructions: TEXT }); + if (flag) { + const TEXT = "block"; + } + return first; +} +const TEXT = "module"; +`)}}, true) + require.NoError(t, err) + agents := map[string]*adapter.Node{} + for _, agent := range root.Fields["agents"].Items { + agents[agent.Fields["id"].Value.(string)] = agent + } + require.Len(t, agents, 3) + assert.Equal(t, "inner", agents["build.Agent"].Fields["instructions"].Value, "a use in the block sees the block constant") + assert.Equal(t, "outer", agents["build.Agent#2"].Fields["instructions"].Value, "a use outside the block sees the function constant") + assert.Equal(t, "module", agents["later.first"].Fields["instructions"].Value, "a block constant does not reach a use outside its block") + ids := []string{} + for _, constant := range root.Fields["constants"].Items { + ids = append(ids, constant.Fields["id"].Value.(string)) + } + assert.ElementsMatch(t, []string{"build.SYSTEM", "TEXT"}, ids, "a block constant is not a constant of the file") +} + +func TestTypeScriptDestructuringDefaultsAreNotBindings(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: []byte(` +import { Agent } from "@openai/agents"; +const SYSTEM = "Help"; +function make({ model = Agent, options: { text = SYSTEM } = {} }) { + return new Agent({ instructions: SYSTEM }); +} +function pick([first = SYSTEM]) { + return new Agent({ instructions: SYSTEM }); +} +`)}}, true) + require.NoError(t, err) + require.Len(t, root.Fields["agents"].Items, 2, "a default named Agent does not hide the import") + for _, agent := range root.Fields["agents"].Items { + assert.Equal(t, "Help", agent.Fields["instructions"].Value, "a default named SYSTEM does not hide the module constant") + } +} + +func TestTypeScriptSingleArrowParametersHideBindings(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: []byte(` +import { Agent } from "@openai/agents"; +const instructions = "Module"; +const make = Agent => new Agent({ instructions: "Not ours" }); +const describe = instructions => new Agent({ instructions }); +const plain = new Agent({ instructions }); +`)}}, true) + require.NoError(t, err) + agents := map[string]*adapter.Node{} + for _, agent := range root.Fields["agents"].Items { + agents[agent.Fields["id"].Value.(string)] = agent + } + require.Len(t, agents, 2, "a parameter named Agent hides the import in its arrow function") + assert.NotEqual(t, adapter.CapabilityDirect, agents["describe.Agent"].Fields["instructions"].Capability, "a parameter hides the module constant") + assert.Equal(t, "Module", agents["plain"].Fields["instructions"].Value) +} + +func TestJavaScriptRestParametersHideImports(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "src/main.js", Data: []byte(` +import { tool } from "ai"; +function collect(...tool) { + const hidden = tool({ description: "Hidden", parameters: { type: "object" } }); + return hidden; +} +const tools = { search: tool({ description: "Search", parameters: { type: "object" } }) }; +`)}}, false) + require.NoError(t, err) + require.Len(t, root.Fields["tools"].Items, 1, "a rest parameter hides the import in its function") + assert.Equal(t, "Search", root.Fields["tools"].Items[0].Fields["description"].Value) +} + +func TestTypeScriptImportsResolveOnlyExportedConstants(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{ + Primary: adapter.Document{Path: "src/main.ts", Data: []byte(` +import { Agent } from "@openai/agents"; +import * as values from "./values"; +import { PRIVATE, RENAMED } from "./values"; +const hidden = new Agent({ instructions: values.PRIVATE }); +const shown = new Agent({ instructions: values.PUBLIC }); +const named = new Agent({ instructions: PRIVATE }); +const renamed = new Agent({ instructions: RENAMED }); +`)}, + Dependencies: map[string]adapter.Document{ + "src/values.ts": {Path: "src/values.ts", Data: []byte(`const PRIVATE = "hidden"; +export const PUBLIC = "shown"; +const LOCAL = "aliased"; +export { LOCAL as RENAMED }; +`)}, + }, + }, true) + require.NoError(t, err) + agents := map[string]*adapter.Node{} + for _, agent := range root.Fields["agents"].Items { + agents[agent.Fields["id"].Value.(string)] = agent + } + assert.NotEqual(t, "hidden", agents["hidden"].Fields["instructions"].Value, "a private constant is not importable") + assert.Equal(t, "shown", agents["shown"].Fields["instructions"].Value) + assert.NotEqual(t, "hidden", agents["named"].Fields["instructions"].Value) + assert.Equal(t, "aliased", agents["renamed"].Fields["instructions"].Value, "an export clause alias is importable") +} + +func TestTypeScriptMCPNamesIdentifyAssignedToolsAndPrompts(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: []byte(` +import { McpServer } from "@modelcontextprotocol/sdk"; +const server = new McpServer({}); +const lookup = server.tool("search", "Search orders.", { type: "object" }, handler); +const greeting = server.prompt("greet", "Greets.", handler); +const registered = server.registerPrompt("summarize", { description: "Summarizes." }, handler); +const tool = server.registerTool("audit", { description: "Audits." }, handler); +`)}}, true) + require.NoError(t, err) + tools := map[string]*adapter.Node{} + for _, tool := range root.Fields["tools"].Items { + tools[tool.Fields["id"].Value.(string)] = tool + } + prompts := map[string]*adapter.Node{} + for _, prompt := range root.Fields["prompts"].Items { + prompts[prompt.Fields["id"].Value.(string)] = prompt + } + assert.Len(t, tools, 2) + assert.Contains(t, tools, "search", "the MCP name is the id of an assigned tool") + assert.Contains(t, tools, "audit") + assert.Len(t, prompts, 2) + assert.Contains(t, prompts, "greet", "the MCP name is the id of an assigned prompt") + assert.Contains(t, prompts, "summarize") +} + +func TestJavaScriptModuleExportsValueIsTheDefaultExport(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{ + Primary: adapter.Document{Path: "src/main.js", Data: []byte(` +const { Agent } = require("@openai/agents"); +const named = require("./named"); +const literal = require("./literal"); +const support = new Agent({ instructions: named }); +const plain = new Agent({ instructions: literal }); +`)}, + Dependencies: map[string]adapter.Document{ + "src/named.js": {Path: "src/named.js", Data: []byte("const TEXT = \"Help\";\nmodule.exports = TEXT;\n")}, + "src/literal.js": {Path: "src/literal.js", Data: []byte("module.exports = \"Plain\";\n")}, + }, + }, false) + require.NoError(t, err) + agents := map[string]*adapter.Node{} + for _, agent := range root.Fields["agents"].Items { + agents[agent.Fields["id"].Value.(string)] = agent + } + assert.Equal(t, "Help", agents["support"].Fields["instructions"].Value, "a module that exports one identifier exports it as the default") + assert.Equal(t, adapter.CapabilityDirect, agents["support"].Fields["instructions"].Capability) + assert.Equal(t, "Plain", agents["plain"].Fields["instructions"].Value, "a module that exports one literal exports it as the default") +} + +func TestTypeScriptStaticMethodsAreScopesOfTheirOwn(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: []byte(` +import { Agent } from "@openai/agents"; +class Bot { + static TEXT = "field"; + static make() { + const TEXT = "static"; + return new Agent({ instructions: TEXT }); + } + make() { + const TEXT = "instance"; + return new Agent({ instructions: TEXT }); + } + static bare() { + return new Agent({ instructions: TEXT }); + } +} +`)}}, true) + require.NoError(t, err) + agents := map[string]*adapter.Node{} + for _, agent := range root.Fields["agents"].Items { + agents[agent.Fields["id"].Value.(string)] = agent + } + require.Len(t, agents, 3) + assert.Equal(t, "static", agents["Bot.static.make.Agent"].Fields["instructions"].Value, "a static method is a scope of the class") + assert.Equal(t, "instance", agents["Bot.make.Agent"].Fields["instructions"].Value, "an instance method of the same name is another scope") + assert.Equal(t, adapter.CapabilityReadOnly, agents["Bot.static.bare.Agent"].Fields["instructions"].Capability, "a bare name in a static method does not read the static field") +} + +func TestTypeScriptAssignedRegisterToolFallsBackToTheVariable(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: []byte(` +import { McpServer } from "@modelcontextprotocol/sdk"; +const server = new McpServer({}); +const lookup = server.registerTool(dynamicName, { description: "Dynamic." }, handler); +const named = server.registerTool("audit", { description: "Audit." }, handler); +server.registerTool(otherName, { description: "Dropped." }, handler); +`)}}, true) + require.NoError(t, err) + tools := map[string]*adapter.Node{} + for _, tool := range root.Fields["tools"].Items { + tools[tool.Fields["id"].Value.(string)] = tool + } + require.Len(t, tools, 2, "an unassigned tool without a static name has no id") + assert.Equal(t, "Dynamic.", tools["lookup"].Fields["description"].Value, "an assigned tool without a static name takes the variable") + assert.Equal(t, "Audit.", tools["audit"].Fields["description"].Value, "the static name wins over the variable") +} + +func TestTypeScriptImportsOfReassignedNamesAreReadOnlyBindings(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{ + Primary: adapter.Document{Path: "src/main.ts", Data: []byte(` +import { Agent } from "@openai/agents"; +import { TEXT, PLAIN } from "./values"; +const support = new Agent({ instructions: TEXT }); +const other = new Agent({ instructions: PLAIN }); +`)}, + Dependencies: map[string]adapter.Document{ + "src/values.ts": {Path: "src/values.ts", Data: []byte("export let TEXT = \"first\";\nTEXT = \"second\";\nexport const PLAIN = \"plain\";\n")}, + }, + }, true) + require.NoError(t, err) + agents := map[string]*adapter.Node{} + for _, agent := range root.Fields["agents"].Items { + agents[agent.Fields["id"].Value.(string)] = agent + } + instructions := agents["support"].Fields["instructions"] + assert.Equal(t, "first", instructions.Value, "the import binds the first literal") + assert.Equal(t, adapter.CapabilityReadOnly, instructions.Capability) + // An assignment to a declared name is a mutation of the binding in the + // TypeScript adapter. + assert.Equal(t, "mutated constants are read-only", instructions.Reason) + assert.Empty(t, instructions.Code, "a reassigned name is not an unresolved import") + assert.Equal(t, "plain", agents["other"].Fields["instructions"].Value) +} + +func TestTypeScriptExportEqualsIsImportable(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{ + Primary: adapter.Document{Path: "src/main.ts", Data: []byte(` +import { Agent } from "@openai/agents"; +import whole = require("./whole"); +import values = require("./values"); +import literal = require("./literal"); +const support = new Agent({ instructions: whole }); +const named = new Agent({ instructions: values.TEXT }); +const plain = new Agent({ instructions: literal }); +`)}, + Dependencies: map[string]adapter.Document{ + "src/whole.ts": {Path: "src/whole.ts", Data: []byte("const TEXT = \"Whole\";\nexport = TEXT;\n")}, + "src/values.ts": {Path: "src/values.ts", Data: []byte("const TEXT = \"Named\";\nexport = { TEXT };\n")}, + "src/literal.ts": {Path: "src/literal.ts", Data: []byte("export = \"Literal\";\n")}, + }, + }, true) + require.NoError(t, err) + agents := map[string]*adapter.Node{} + for _, agent := range root.Fields["agents"].Items { + agents[agent.Fields["id"].Value.(string)] = agent + } + assert.Equal(t, "Whole", agents["support"].Fields["instructions"].Value, "export = TEXT exports the value as the module") + assert.Equal(t, "Named", agents["named"].Fields["instructions"].Value, "export = { TEXT } exports its entries") + assert.Equal(t, "Literal", agents["plain"].Fields["instructions"].Value, "export = \"text\" exports the literal as the module") +} + +func TestTypeScriptConstructorParametersKeepTheClientKind(t *testing.T) { + typed, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: []byte(` +import OpenAI from "openai"; +class Typed { + client: OpenAI; + constructor(client: OpenAI) { + this.client = client; + } + ask() { + return this.client.chat.completions.create({ model: "gpt-5", messages: [{ role: "user", content: "typed" }] }); + } +} +`)}}, true) + require.NoError(t, err) + require.Len(t, typed.Fields["prompts"].Items, 1, "the type of the parameter names the client kind") + + plain, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.js", Data: []byte(` +const OpenAI = require("openai"); +class Plain { + client = new OpenAI(); + constructor(client) { + this.client = client; + } + ask() { + return this.client.chat.completions.create({ model: "gpt-5", messages: [{ role: "user", content: "plain" }] }); + } +} +`)}}, false) + require.NoError(t, err) + require.Len(t, plain.Fields["prompts"].Items, 1, "a parameter without a type does not wipe the kind of the field") +} + +func TestTypeScriptNamedAndNamespaceImportsShareMutations(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{ + Primary: adapter.Document{Path: "src/main.ts", Data: []byte(` +import * as values from "./values"; +import { LIST, OTHER, PLAIN } from "./values"; +LIST.push("b"); +values.OTHER.push("c"); +const fromNamespace = [values.LIST]; +const fromNamed = [OTHER]; +const untouched = [values.PLAIN, PLAIN]; +`)}, + Dependencies: map[string]adapter.Document{ + "src/values.ts": {Path: "src/values.ts", Data: []byte("export const LIST = [\"a\"];\nexport const OTHER = [\"a\"];\nexport const PLAIN = [\"a\"];\n")}, + }, + }, true) + require.NoError(t, err) + constants := map[string]*adapter.Node{} + for _, constant := range root.Fields["constants"].Items { + constants[constant.Fields["id"].Value.(string)] = constant + } + assert.Equal(t, adapter.CapabilityReadOnly, constants["fromNamespace"].Capability, "a mutation through the named import freezes the namespace field") + assert.Equal(t, adapter.CapabilityReadOnly, constants["fromNamed"].Capability, "a mutation through the namespace freezes the named import") + assert.Equal(t, adapter.CapabilityDirect, constants["untouched"].Capability, "an untouched export stays writable through both names") +} + +func TestTypeScriptStaticFieldInitializersAreScopesOfTheClass(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: []byte(` +import { Agent } from "@openai/agents"; +class Bot { + static build = () => { + const TEXT = "static"; + return new Agent({ instructions: TEXT }); + }; + build = () => { + const TEXT = "instance"; + return new Agent({ instructions: TEXT }); + }; +} +`)}}, true) + require.NoError(t, err) + agents := map[string]*adapter.Node{} + for _, agent := range root.Fields["agents"].Items { + agents[agent.Fields["id"].Value.(string)] = agent + } + require.Len(t, agents, 2) + assert.Equal(t, "static", agents["Bot.static.build.Agent"].Fields["instructions"].Value, "a static field initializer is a scope of the class") + assert.Equal(t, "instance", agents["Bot.build.Agent"].Fields["instructions"].Value, "an instance field of the same name is another scope") +} + +func TestTypeScriptImportedAliasesShareMutations(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{ + Primary: adapter.Document{Path: "src/main.ts", Data: []byte(` +import { ITEMS, ALSO, OTHER } from "./values"; +ITEMS.push("b"); +const wrapped = [ALSO]; +const plain = [OTHER]; +`)}, + Dependencies: map[string]adapter.Document{ + "src/values.ts": {Path: "src/values.ts", Data: []byte("export const ITEMS = [\"a\"];\nexport const ALSO = ITEMS;\nexport const OTHER = [\"c\"];\n")}, + }, + }, true) + require.NoError(t, err) + constants := map[string]*adapter.Node{} + for _, constant := range root.Fields["constants"].Items { + constants[constant.Fields["id"].Value.(string)] = constant + } + assert.Equal(t, adapter.CapabilityReadOnly, constants["wrapped"].Capability, "an alias of the mutated import binds the same object") + assert.Equal(t, adapter.CapabilityDirect, constants["plain"].Capability) +} + +func TestTypeScriptTypedParameterPropertiesAreClients(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: []byte(` +import OpenAI from "openai"; +import Anthropic from "@anthropic-ai/sdk"; +class Bot { + constructor(private client: OpenAI, private readonly backup: Anthropic, private label: string) {} + ask() { + return this.client.chat.completions.create({ model: "gpt-5", messages: [{ role: "user", content: "first" }] }); + } + fallback() { + return this.backup.messages.create({ model: "claude", messages: [{ role: "user", content: "second" }] }); + } +} +`)}}, true) + require.NoError(t, err) + contents := []string{} + for _, prompt := range root.Fields["prompts"].Items { + messages := prompt.Fields["messages"].Value.([]syncdomain.Message) + contents = append(contents, messages[0].Content) + } + assert.ElementsMatch(t, []string{"first", "second"}, contents, "the type of a parameter property names the client kind") +} + +func TestTypeScriptDefaultImportOfACommonJSObjectModule(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{ + Primary: adapter.Document{Path: "src/main.ts", Data: []byte(` +import { Agent } from "@openai/agents"; +import values from "./values"; +import equals from "./equals"; +const support = new Agent({ instructions: values.TEXT }); +const other = new Agent({ instructions: equals.TEXT }); +`)}, + Dependencies: map[string]adapter.Document{ + "src/values.ts": {Path: "src/values.ts", Data: []byte("const TEXT = \"Object\";\nmodule.exports = { TEXT };\n")}, + "src/equals.ts": {Path: "src/equals.ts", Data: []byte("const TEXT = \"Equals\";\nexport = { TEXT };\n")}, + }, + }, true) + require.NoError(t, err) + agents := map[string]*adapter.Node{} + for _, agent := range root.Fields["agents"].Items { + agents[agent.Fields["id"].Value.(string)] = agent + } + assert.Equal(t, "Object", agents["support"].Fields["instructions"].Value, "a default import of a module.exports object binds the object") + assert.Equal(t, "Equals", agents["other"].Fields["instructions"].Value, "a default import of an export = object binds the object") +} + +func TestTypeScriptStaticClientsResolveThroughTheClassName(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: []byte(` +import OpenAI from "openai"; +class Bot { + static client = new OpenAI(); + static ask() { + return Bot.client.chat.completions.create({ model: "gpt-5", messages: [{ role: "user", content: "inside" }] }); + } +} +const outside = Bot.client.chat.completions.create({ model: "gpt-5", messages: [{ role: "user", content: "outside" }] }); +`)}}, true) + require.NoError(t, err) + contents := []string{} + for _, prompt := range root.Fields["prompts"].Items { + messages := prompt.Fields["messages"].Value.([]syncdomain.Message) + contents = append(contents, messages[0].Content) + } + assert.ElementsMatch(t, []string{"inside", "outside"}, contents, "the class name reaches the static client") +} + +func TestTypeScriptTypeOnlyImportsNameParameterKinds(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: []byte(` +import type OpenAI from "openai"; +import { type Anthropic } from "@anthropic-ai/sdk"; +class Bot { + private backup: Anthropic; + constructor(private client: OpenAI | undefined, backup: Anthropic) { + this.backup = backup; + } + ask() { + return this.client.chat.completions.create({ model: "gpt-5", messages: [{ role: "user", content: "first" }] }); + } + fallback() { + return this.backup.messages.create({ model: "claude", messages: [{ role: "user", content: "second" }] }); + } +} +`)}}, true) + require.NoError(t, err) + contents := []string{} + for _, prompt := range root.Fields["prompts"].Items { + messages := prompt.Fields["messages"].Value.([]syncdomain.Message) + contents = append(contents, messages[0].Content) + } + assert.ElementsMatch(t, []string{"first", "second"}, contents, "a type-only import and a union type name the client kind") +} + +func TestTypeScriptLocalsOfEveryFormHideOuterConstants(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: []byte(` +import { Agent } from "@openai/agents"; +const TEXT = "module"; +function destructured(options: { TEXT: string }) { + const { TEXT } = options; + return new Agent({ instructions: TEXT }); +} +function uninitialized() { + let TEXT; + return new Agent({ instructions: TEXT }); +} +function looped(items: string[]) { + const agents = []; + for (const TEXT of items) { + agents.push(new Agent({ instructions: TEXT })); + } + return agents; +} +const plain = new Agent({ instructions: TEXT }); +`)}}, true) + require.NoError(t, err) + agents := map[string]*adapter.Node{} + for _, agent := range root.Fields["agents"].Items { + agents[agent.Fields["id"].Value.(string)] = agent + } + for _, id := range []string{"destructured.Agent", "uninitialized.Agent", "looped.Agent"} { + require.NotNil(t, agents[id], id) + assert.Equal(t, adapter.CapabilityReadOnly, agents[id].Fields["instructions"].Capability, "%s hides the module constant", id) + } + assert.Equal(t, "module", agents["plain"].Fields["instructions"].Value) +} + +func TestTypeScriptAnonymousClassesKeepTheirClients(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: []byte(` +import OpenAI from "openai"; +export default class { + client = new OpenAI(); + ask() { + return this.client.chat.completions.create({ model: "gpt-5", messages: [{ role: "user", content: "default" }] }); + } +} +const Mixin = (Base: any) => class extends Base { + client = new OpenAI(); + ask() { + return this.client.chat.completions.create({ model: "gpt-5", messages: [{ role: "user", content: "mixin" }] }); + } +}; +`)}}, true) + require.NoError(t, err) + contents := []string{} + for _, prompt := range root.Fields["prompts"].Items { + messages := prompt.Fields["messages"].Value.([]syncdomain.Message) + contents = append(contents, messages[0].Content) + } + assert.ElementsMatch(t, []string{"default", "mixin"}, contents, "a field and a method of an anonymous class share one scope") +} + +func TestTypeScriptClassNameReadsTheStaticClientFirst(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: []byte(` +import OpenAI from "openai"; +import Anthropic from "@anthropic-ai/sdk"; +class Bot { + static client = new Anthropic(); + client = new OpenAI(); + static ask() { + return Bot.client.messages.create({ model: "claude", messages: [{ role: "user", content: "static" }] }); + } + ask() { + return this.client.chat.completions.create({ model: "gpt-5", messages: [{ role: "user", content: "instance" }] }); + } +} +`)}}, true) + require.NoError(t, err) + contents := []string{} + for _, prompt := range root.Fields["prompts"].Items { + messages := prompt.Fields["messages"].Value.([]syncdomain.Message) + contents = append(contents, messages[0].Content) + } + assert.ElementsMatch(t, []string{"static", "instance"}, contents, "the class name reads the static field, and this reads the instance field") +} + +func TestTypeScriptIdentifierAssignmentsKeepOrMoveTheClientKind(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: []byte(` +import OpenAI from "openai"; +import Anthropic from "@anthropic-ai/sdk"; +class Bot { + client = new OpenAI(); + constructor() { + const kept = this.client; + this.client = kept; + } + ask() { + return this.client.chat.completions.create({ model: "gpt-5", messages: [{ role: "user", content: "kept" }] }); + } +} +let shared; +const backup = new Anthropic(); +shared = backup; +const moved = shared.messages.create({ model: "claude", messages: [{ role: "user", content: "moved" }] }); +`)}}, true) + require.NoError(t, err) + contents := []string{} + for _, prompt := range root.Fields["prompts"].Items { + messages := prompt.Fields["messages"].Value.([]syncdomain.Message) + contents = append(contents, messages[0].Content) + } + assert.ElementsMatch(t, []string{"kept", "moved"}, contents, "a name without a kind keeps the property kind, and a name with a kind moves it") +} + +func TestTypeScriptTypedParametersAreClients(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: []byte(` +import type OpenAI from "openai"; +import Anthropic from "@anthropic-ai/sdk"; +function run(client: OpenAI) { + return client.chat.completions.create({ model: "gpt-5", messages: [{ role: "user", content: "function" }] }); +} +class Bot { + ask(client?: Anthropic) { + return client.messages.create({ model: "claude", messages: [{ role: "user", content: "method" }] }); + } +} +`)}}, true) + require.NoError(t, err) + contents := []string{} + for _, prompt := range root.Fields["prompts"].Items { + messages := prompt.Fields["messages"].Value.([]syncdomain.Message) + contents = append(contents, messages[0].Content) + } + assert.ElementsMatch(t, []string{"function", "method"}, contents, "the type of a parameter names the client kind in its function") +} + +func TestTypeScriptLocalsShadowOuterClients(t *testing.T) { + root, err := parseTypeScriptBundle(adapter.SourceBundle{Primary: adapter.Document{Path: "main.ts", Data: []byte(` +import OpenAI from "openai"; +const client = new OpenAI(); +function untyped(client) { + return client.chat.completions.create({ model: "gpt-5", messages: [{ role: "user", content: "untyped" }] }); +} +function uninitialized() { + let client; + return client.chat.completions.create({ model: "gpt-5", messages: [{ role: "user", content: "uninitialized" }] }); +} +function destructured(deps) { + const { client } = deps; + return client.chat.completions.create({ model: "gpt-5", messages: [{ role: "user", content: "destructured" }] }); +} +function looped(list) { + for (const client of list) { + client.chat.completions.create({ model: "gpt-5", messages: [{ role: "user", content: "looped" }] }); + } +} +function aliased(other) { + const client = other; + return client.chat.completions.create({ model: "gpt-5", messages: [{ role: "user", content: "aliased" }] }); +} +const top = client.chat.completions.create({ model: "gpt-5", messages: [{ role: "user", content: "top" }] }); +`)}}, true) + require.NoError(t, err) + contents := []string{} + for _, prompt := range root.Fields["prompts"].Items { + messages := prompt.Fields["messages"].Value.([]syncdomain.Message) + contents = append(contents, messages[0].Content) + } + assert.Equal(t, []string{"top"}, contents, "every local form of the name hides the module client") +} diff --git a/internal/sync/adapter/ecmascript/frameworks.go b/internal/sync/adapter/ecmascript/frameworks.go new file mode 100644 index 000000000..aba89fd82 --- /dev/null +++ b/internal/sync/adapter/ecmascript/frameworks.go @@ -0,0 +1,365 @@ +package ecmascript + +import ( + "strings" + + sitter "github.com/tree-sitter/go-tree-sitter" + + "github.com/launchdarkly/ldcli/internal/sync/adapter" + "github.com/launchdarkly/ldcli/internal/sync/adapter/code" +) + +type typeScriptImportRule struct { + module string + symbols map[string]string +} + +var typeScriptFrameworkAllowlist = []typeScriptImportRule{ + {module: "@modelcontextprotocol/sdk", symbols: map[string]string{"McpServer": "mcp-server"}}, + {module: "openai", symbols: map[string]string{"default": "openai-client", "OpenAI": "openai-client"}}, + {module: "@anthropic-ai/sdk", symbols: map[string]string{"default": "anthropic-client", "Anthropic": "anthropic-client"}}, + {module: "ai", symbols: map[string]string{ + "generateText": "ai-prompt", "streamText": "ai-prompt", "tool": "vercel-tool", + }}, + {module: "@openai/agents", symbols: map[string]string{"Agent": "openai-agent", "tool": "openai-tool"}}, + {module: "@langchain/core/prompts", symbols: map[string]string{ + "PromptTemplate": "prompt", "ChatPromptTemplate": "prompt", + }}, + {module: "@langchain/core/tools", symbols: map[string]string{"tool": "langchain-tool"}}, + {module: "@mastra/core/agent", symbols: map[string]string{"Agent": "mastra-agent"}}, + {module: "@mastra/core/tools", symbols: map[string]string{"createTool": "mastra-tool"}}, +} + +// typeScriptCallRule describes the calls of one framework kind. placeholders +// is the placeholder style that the framework fills at run time. The +// frameworks here send their text as it is, so none translates, and braces +// or template syntax in a prompt stay literal. +type typeScriptCallRule struct { + collection string + objectArg int + placeholders code.PlaceholderStyle +} + +var typeScriptCallRules = map[string]typeScriptCallRule{ + "openai-agent": {collection: "agents", objectArg: 0}, + "mastra-agent": {collection: "agents", objectArg: 0}, + "ai-prompt": {collection: "prompts", objectArg: 0}, + "vercel-tool": {collection: "tools", objectArg: 0}, + "openai-tool": {collection: "tools", objectArg: 0}, + "langchain-tool": {collection: "tools", objectArg: 1}, + "mastra-tool": {collection: "tools", objectArg: 0}, + "openai-request": {collection: "prompts", objectArg: 0}, + "anthropic-request": {collection: "prompts", objectArg: 0}, +} + +func typeScriptAllowedImports(module string) map[string]string { + for _, rule := range typeScriptFrameworkAllowlist { + if module == rule.module || strings.HasPrefix(module, rule.module+"/") { + return rule.symbols + } + } + return nil +} + +// typeScriptInstances maps each variable that holds a framework client or +// agent to its kind, by qualified id. A variable that two different +// frameworks assign in one scope is ambiguous and has no kind. +func typeScriptInstances(root *sitter.Node, source []byte, imports, typeImports typeScriptAliases) map[string]string { + instances := map[string]string{} + ambiguous := map[string]bool{} + // unset holds the names that a binding without a value declares, such + // as `let client;` or an untyped parameter. The entry shadows an outer + // instance of the name, and a later assignment gives the name its kind + // without making it ambiguous. + unset := map[string]bool{} + record := func(id, kind string) { + if previous, seen := instances[id]; seen && !unset[id] && previous != kind { + ambiguous[id] = true + } + delete(unset, id) + instances[id] = kind + } + declare := func(id string) { + if _, seen := instances[id]; !seen { + instances[id], unset[id] = "", true + } + } + code.Walk(root, func(node *sitter.Node) { + // A binding without a value shadows an outer client of the same + // name: an uninitialized let, a destructuring, a for-of target, a + // catch parameter, a local function or class, and a parameter. A + // typed parameter, as `function run(client: OpenAI)` or + // `constructor(private client: OpenAI)`, takes the kind of its + // type. A parameter with a default is a binding of that value below. + switch { + case node.Kind() == "for_in_statement": + for _, bound := range typeScriptBoundNames(node.ChildByFieldName("left"), source) { + declare(typeScriptBlockID(typeScriptQualifiedID(node, bound, source), node)) + } + return + case node.Kind() == "variable_declarator" && + (node.ChildByFieldName("value") == nil || node.ChildByFieldName("name").Kind() != "identifier"): + // A destructuring binds its names to parts of a value, which + // have no kind of their own. + for _, bound := range typeScriptBoundNames(node.ChildByFieldName("name"), source) { + declare(typeScriptBindingID(node, bound, source)) + } + return + case node.Kind() != "assignment_pattern" && node.ChildByFieldName("value") == nil: + pattern := typeScriptLocalBinding(node) + if pattern == nil { + break + } + kind := "" + if node.Kind() == "required_parameter" || node.Kind() == "optional_parameter" { + kind = typeScriptParameterKind(node, node, typeImports, source) + } + if typeScriptParameterProperty(node) { + if kind != "" { + record(typeScriptInstanceID(node, pattern, source), kind) + } + return + } + for _, bound := range typeScriptBoundNames(pattern, source) { + if kind != "" { + record(typeScriptBindingID(node, bound, source), kind) + } else { + declare(typeScriptBindingID(node, bound, source)) + } + } + return + } + name, value := typeScriptBinding(node) + if name == nil || value == nil { + return + } + // A binding of another value has no kind. It still has an entry, so + // that a local name shadows an outer instance of the same name. + kind := "" + if value.Kind() == "new_expression" || value.Kind() == "call_expression" { + kind = imports.at(node, source)[typeScriptCallable(value, source)] + } + // A reset, such as `this.client = null` in a cleanup, does not + // change what kind of client the name holds. + if value.Kind() == "null" || value.Kind() == "undefined" || value.Kind() == "identifier" && code.Text(value, source) == "undefined" { + return + } + // `this.client = client` stores the value of another name. The + // type of a parameter or the kind of the name decides. A name + // without a kind does not change the kind that a property holds, + // because the parameter or the name can hold any client, while a + // local bound to such a name shadows an outer client. + if value.Kind() == "identifier" { + other := code.Text(value, source) + if parameter, ok := typeScriptParameterOf(node, other, source); ok { + kind = typeScriptParameterKind(parameter, node, typeImports, source) + } else { + kind = typeScriptInstanceKind(instances, node, other, source) + } + if kind == "" { + if typeScriptThisProperty(name) { + return + } + id := typeScriptInstanceID(node, name, source) + if _, seen := instances[id]; seen { + // A name that held a client holds an unknown value now. + record(id, "") + } else { + declare(id) + } + return + } + } + record(typeScriptInstanceID(node, name, source), kind) + }) + for id := range ambiguous { + delete(instances, id) + } + return instances +} + +func typeScriptFrameworkCall( + call *sitter.Node, + id string, + imports typeScriptAliases, + instances map[string]string, + source []byte, + constants map[string]*adapter.Node, +) (collection string, resource *adapter.Node, ok bool) { + call = typeScriptUnwrap(call) + if call == nil || call.Kind() != "call_expression" && call.Kind() != "new_expression" { + return "", nil, false + } + callable := typeScriptCallable(call, source) + kind := imports.at(call, source)[callable] + if strings.Contains(callable, ".") { + ownerKind, method := code.Receiver(callable, func(receiver string) string { + return typeScriptInstanceKind(instances, call, receiver, source) + }) + switch ownerKind { + case "openai-client": + if method == "chat.completions.create" || method == "responses.create" { + kind = "openai-request" + } + case "anthropic-client": + if method == "messages.create" { + kind = "anthropic-request" + } + case "mcp-server": + switch method { + case "tool": + kind = "mcp-tool" + case "registerTool": + kind = "mcp-registered-tool" + case "registerPrompt", "prompt": + kind = "mcp-prompt" + } + } + } + // A Vercel AI SDK tool is named by its key in the tools object, which the + // tools collector reads, so a call without a key is not a resource. + if kind == "vercel-tool" && id == "" { + return "", nil, false + } + rule, exists := typeScriptCallRules[kind] + arguments := code.NamedChildren(call.ChildByFieldName("arguments")) + for index := range arguments { + arguments[index] = typeScriptUnwrap(arguments[index]) + } + if !exists { + switch kind { + case "mcp-tool": + return typeScriptMCPTool(arguments, id, source, constants) + case "mcp-registered-tool": + return typeScriptMCPRegisteredTool(arguments, id, source, constants) + case "mcp-prompt": + return typeScriptMCPPrompt(arguments, id, source, constants) + } + return "", nil, false + } + if rule.objectArg >= len(arguments) { + return "", nil, false + } + // The config can be a literal object or a constant that holds one. A + // literal object that sync cannot read, such as one with a duplicate + // key, still names the resource, so the plan can report it. + config := typeScriptObject(arguments[rule.objectArg], source, constants) + if config.Fields == nil && arguments[rule.objectArg].Kind() != "object" { + return "", nil, false + } + fields := config.Fields + if rule.placeholders != "" { + switch rule.collection { + case "agents": + fields = code.TranslateFields( + fields, rule.placeholders, "instructions", "prompt", "system", + ) + case "prompts": + fields = code.TranslateFields( + fields, rule.placeholders, "instructions", "prompt", "system", "messages", + ) + } + } + if id == "" { + id = code.StaticString(fields["name"]) + } + if id == "" { + id = code.StaticString(fields["id"]) + } + if id == "" { + id = typeScriptQualifiedID(call, callable, source) + } + return rule.collection, adapter.Resource(id, fields), true +} + +// typeScriptMCPRegisteredTool reads `server.registerTool(name, config, +// handler)`. The MCP name identifies the tool, and the config object holds +// its fields. A name that is not static falls back to the variable, as for +// the other MCP methods. +func typeScriptMCPRegisteredTool( + arguments []*sitter.Node, + id string, + source []byte, + constants map[string]*adapter.Node, +) (string, *adapter.Node, bool) { + if len(arguments) < 2 { + return "", nil, false + } + if static := code.StaticString(typeScriptValue(arguments[0], source, constants)); static != "" { + id = static + } + fields := typeScriptObject(arguments[1], source, constants).Fields + return "tools", adapter.Resource(id, fields), id != "" +} + +func typeScriptMCPTool( + arguments []*sitter.Node, + id string, + source []byte, + constants map[string]*adapter.Node, +) (string, *adapter.Node, bool) { + // server.tool takes a name, an optional description, an optional + // schema, and the handler last. A string after the name is the + // description, and an object is the schema. + if len(arguments) < 2 { + return "", nil, false + } + name := typeScriptValue(arguments[0], source, constants) + // The MCP name identifies the tool, as it does for registerTool, so a + // call assigned to a variable keeps the same id as a bare call. A name + // that is not static falls back to the variable. + if static := code.StaticString(name); static != "" { + id = static + } + fields := map[string]*adapter.Node{"name": name} + options := arguments[1 : len(arguments)-1] + if len(options) != 0 { + candidate := typeScriptValue(options[0], source, constants) + if _, isString := candidate.Value.(string); isString || + options[0].Kind() == "string" || options[0].Kind() == "template_string" { + fields["description"] = candidate + options = options[1:] + } + } + if len(options) != 0 { + fields["schema"] = typeScriptValue(options[0], source, constants) + } + return "tools", adapter.Resource(id, fields), id != "" +} + +func typeScriptMCPPrompt( + arguments []*sitter.Node, + id string, + source []byte, + constants map[string]*adapter.Node, +) (string, *adapter.Node, bool) { + if len(arguments) == 0 { + return "", nil, false + } + name := typeScriptValue(arguments[0], source, constants) + // The MCP name identifies the prompt, assigned or not, as for a tool. + if static := code.StaticString(name); static != "" { + id = static + } + fields := map[string]*adapter.Node{"name": name} + if len(arguments) > 1 { + config := typeScriptValue(arguments[1], source, constants) + if _, isString := config.Value.(string); isString || + arguments[1].Kind() == "string" || arguments[1].Kind() == "template_string" { + // `server.prompt(name, description, ...)` of the older API + // takes the description as a string. + fields["description"] = config + } else { + // The config of registerPrompt can be a literal object or a + // constant that holds one. + for key, value := range typeScriptObject(arguments[1], source, constants).Fields { + fields[key] = value + } + } + } + fields["template"] = adapter.ReadOnly( + int(arguments[0].StartByte()), int(arguments[len(arguments)-1].EndByte()), + "prompt built by a handler is read-only", + ) + return "prompts", adapter.Resource(id, fields), id != "" +} diff --git a/internal/sync/adapter/ecmascript/testdata/frameworks.ts b/internal/sync/adapter/ecmascript/testdata/frameworks.ts new file mode 100644 index 000000000..e04ac5b0e --- /dev/null +++ b/internal/sync/adapter/ecmascript/testdata/frameworks.ts @@ -0,0 +1,90 @@ +import Anthropic from "@anthropic-ai/sdk"; +import { Agent, tool as agentTool } from "@openai/agents"; +import { Agent as MastraAgent } from "@mastra/core/agent"; +import { createTool } from "@mastra/core/tools"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { tool as langchainTool } from "@langchain/core/tools"; +import { generateText, streamText, tool } from "ai"; +import OpenAI from "openai"; + +const openai = new OpenAI(); +const anthropic = new Anthropic(); +const server = new McpServer({ name: "support", version: "1.0.0" }); + +const openaiChat = openai.chat.completions.create({ + model: "gpt-5", + messages: [{ role: "system", content: "Answer support questions." }], + tools: [{ type: "function", name: "lookup" }], +}); + +const openaiResponse = openai.responses.create({ + instructions: "Answer in one paragraph.", +}); + +const anthropicMessage = anthropic.messages.create({ + system: "Follow the support policy.", + messages: [{ role: "user", content: "Help with an order." }], + tools: [{ name: "lookup", description: "Look up an order.", input_schema: { type: "object" } }], +}); + +const generated = generateText({ + system: "Follow the support policy.", + prompt: "Explain refunds.", + tools: {}, +}); + +const streamed = streamText({ + messages: [{ role: "system", content: "Stream support answers." }], +}); + +const vercelLookup = tool({ + description: "Look up one order.", + inputSchema: { type: "object" }, +}); + +const supportAgent = new Agent({ + name: "Support", + instructions: "Ask for an order ID.", + model: "gpt-5", + tools: [], +}); + +const openaiLookup = agentTool({ + name: "openai_lookup", + description: "Look up one order with OpenAI Agents.", + parameters: { type: "object" }, +}); + +const langchainLookup = langchainTool(lookupOrder, { + name: "langchain_lookup", + description: "Look up one order with LangChain.", + schema: z.object({ orderId: z.string() }), +}); + +server.tool( + "mcp_lookup", + "Look up one order through MCP.", + { orderId: z.string() }, + lookupOrder, +); + +server.registerPrompt("mcp_prompt", { + description: "Build one MCP prompt.", +}, promptHandler); + +const mastraAgent = new MastraAgent({ + name: "Support", + instructions: "Ask for an order ID with Mastra.", + model: "openai/gpt-5", +}); + +const mastraLookup = createTool({ + id: "mastra_lookup", + description: "Look up one order with Mastra.", + inputSchema: { type: "object" }, +}); + +function bareCalls() { + openai.responses.create({ instructions: "Bare OpenAI response." }); + anthropic.messages.create({ system: "Bare Anthropic message.", messages: [] }); +} diff --git a/internal/sync/adapter/ecmascript/testdata/imports/src/agent.ts b/internal/sync/adapter/ecmascript/testdata/imports/src/agent.ts new file mode 100644 index 000000000..aa4cae5bc --- /dev/null +++ b/internal/sync/adapter/ecmascript/testdata/imports/src/agent.ts @@ -0,0 +1,8 @@ +import { Agent } from "@openai/agents"; +import { SUPPORT_PROMPT } from "./prompts"; + +export const support = new Agent({ + name: "Support", + instructions: SUPPORT_PROMPT, + model: "gpt-5", +}); diff --git a/internal/sync/adapter/ecmascript/testdata/imports/src/prompts.ts b/internal/sync/adapter/ecmascript/testdata/imports/src/prompts.ts new file mode 100644 index 000000000..2bb8bd618 --- /dev/null +++ b/internal/sync/adapter/ecmascript/testdata/imports/src/prompts.ts @@ -0,0 +1 @@ +export const SUPPORT_PROMPT = "Ask for an order ID before discussing an order."; diff --git a/internal/sync/adapter/ecmascript/testdata/langchain.ts b/internal/sync/adapter/ecmascript/testdata/langchain.ts new file mode 100644 index 000000000..71481fe96 --- /dev/null +++ b/internal/sync/adapter/ecmascript/testdata/langchain.ts @@ -0,0 +1,23 @@ +import { ChatPromptTemplate, PromptTemplate } from "@langchain/core/prompts"; + +const SHARED_MESSAGES = [ + ["system", "Use shared instructions."], + ["human", "{question}"], +]; + +const chat = ChatPromptTemplate.fromMessages([ + ["system", "You route support requests."], + ["human", "Help with {request}."], +]); +const sharedChat = ChatPromptTemplate.fromMessages(SHARED_MESSAGES); + +const simple = PromptTemplate.fromTemplate("Explain {topic}."); +const constructor = new ChatPromptTemplate([ + ["system", "Use the company policy."], + ["human", "{question}"], +]); + +function build() { + const prompt = "Function-local prompt."; + return prompt; +} diff --git a/internal/sync/adapter/ecmascript/testdata/server.js b/internal/sync/adapter/ecmascript/testdata/server.js new file mode 100644 index 000000000..38a4de466 --- /dev/null +++ b/internal/sync/adapter/ecmascript/testdata/server.js @@ -0,0 +1,12 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +const server = new McpServer({ name: "weather", version: "1.0.0" }); + +server.registerTool("weather", { + description: "Return the weather for one city.", + inputSchema: { + type: "object", + properties: { city: { type: "string" } }, + required: ["city"], + }, +}, getWeather); diff --git a/internal/sync/adapter/ecmascript/testdata/server.ts b/internal/sync/adapter/ecmascript/testdata/server.ts new file mode 100644 index 000000000..f1d5d177e --- /dev/null +++ b/internal/sync/adapter/ecmascript/testdata/server.ts @@ -0,0 +1,21 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +const server = new McpServer({ name: "orders", version: "1.0.0" }); + +server.registerTool("lookup_order", { + description: "Look up one order.", + inputSchema: { + type: "object", + properties: { orderId: { type: "string" } }, + required: ["orderId"], + }, +}, lookupOrder); + +server.registerTool("refund_order", { + description: `Refund one order.`, + inputSchema: { + type: "object", + properties: { orderId: { type: "string" }, reason: { type: "string" } }, + required: ["orderId", "reason"], + }, +}, refundOrder); diff --git a/internal/sync/adapter/python/testdata/agents.py b/internal/sync/adapter/python/testdata/agents.py new file mode 100644 index 000000000..373e51112 --- /dev/null +++ b/internal/sync/adapter/python/testdata/agents.py @@ -0,0 +1,22 @@ +from agents import Agent, ModelSettings +from langchain_core.prompts import PromptTemplate + +# Shared text stays in this module. +SUMMARY = """Summarize the support case. +Keep the answer short.""" + +support = Agent( + name="Support", + instructions="Ask for an order ID before discussing an order.", + model="gpt-5", + model_settings=ModelSettings(temperature=0.2), +) + +refunds = Agent( + name='Refunds', + instructions='Explain the refund policy.', + model='gpt-5', + model_settings=ModelSettings(temperature=0.1), +) + +summary = PromptTemplate(template=SUMMARY) diff --git a/internal/sync/adapter/python/testdata/frameworks_tier1.py b/internal/sync/adapter/python/testdata/frameworks_tier1.py new file mode 100644 index 000000000..db41aaac9 --- /dev/null +++ b/internal/sync/adapter/python/testdata/frameworks_tier1.py @@ -0,0 +1,81 @@ +from anthropic import Anthropic +from agents import Agent, function_tool +from langchain_core.tools import StructuredTool, tool +from langgraph.prebuilt import create_react_agent +from mcp.server.fastmcp import FastMCP +from openai import OpenAI + +openai = OpenAI() +anthropic = Anthropic() +mcp = FastMCP("support") + +openai_chat = openai.chat.completions.create( + model="gpt-5", + messages=[{"role": "system", "content": "Answer support questions."}], + tools=[{"type": "function", "name": "lookup"}], +) + +openai_response = openai.responses.create( + model="gpt-5", + instructions="Answer in one paragraph.", +) + +anthropic_message = anthropic.messages.create( + model="claude-sonnet", + system="Follow the support policy.", + messages=[{"role": "user", "content": "Help with an order."}], + tools=[{"name": "lookup", "description": "Look up an order.", "input_schema": {"type": "object"}}], +) + +support_agent = Agent( + name="Support", + instructions="Ask for an order ID.", + model="gpt-5", +) + +react_agent = create_react_agent( + "gpt-5", + [lookup_order], + prompt="Use the support tools.", +) + + +@function_tool +def lookup_order(order_id: str) -> str: + """Look up one order.""" + return order_id + + +@tool +def refund_order(order_id: str) -> str: + """Refund one order.""" + return order_id + + +structured = StructuredTool.from_function( + lookup_order, + name="structured_lookup", + description="Look up one order with a structured tool.", +) + +support_tool = support_agent.as_tool( + tool_name="support_tool", + tool_description="Ask the support agent.", +) + + +@mcp.tool() +def mcp_lookup(order_id: str) -> str: + """Look up one order through MCP.""" + return order_id + + +@mcp.prompt() +def mcp_prompt(topic: str) -> str: + """Build one support prompt.""" + return f"Explain {topic}" + + +def bare_calls(): + openai.responses.create(model="gpt-5", instructions="Bare response prompt.") + anthropic.messages.create(model="claude-sonnet", system="Bare Anthropic prompt.", messages=[]) diff --git a/internal/sync/adapter/python/testdata/frameworks_tier2.py b/internal/sync/adapter/python/testdata/frameworks_tier2.py new file mode 100644 index 000000000..2b7eb423d --- /dev/null +++ b/internal/sync/adapter/python/testdata/frameworks_tier2.py @@ -0,0 +1,83 @@ +from autogen_agentchat.agents import AssistantAgent +from crewai import Agent as CrewAgent, Task +from google.adk.agents import Agent as ADKAgent, LlmAgent +from llama_index.core import PromptTemplate +from llama_index.core.agent import ReActAgent +from llama_index.core.tools import FunctionTool +from pydantic_ai import Agent as PydanticAgent +from semantic_kernel.agents import ChatCompletionAgent +from semantic_kernel.functions import kernel_function +from strands import Agent as StrandsAgent +from strands.tools import tool + +crew_researcher = CrewAgent( + role="Support researcher", + goal="Find the relevant support policy.", + backstory="You maintain support documentation.", +) +crew_task = Task( + description="Research the refund request.", + expected_output="A policy summary.", +) + +pydantic_agent = PydanticAgent( + "openai:gpt-5", + system_prompt="Follow the support policy.", + instructions="Ask for an order ID.", +) + +adk_agent = ADKAgent( + name="support", + model="gemini-2.5-pro", + instruction="Ask for an order ID.", + description="Support agent", +) +llm_agent = LlmAgent("refunds", "gemini-2.5-pro", "Explain refunds.", "Refund agent") + +strands_agent = StrandsAgent( + system_prompt="Follow the support policy.", + tools=[lookup_order], +) + +autogen_agent = AssistantAgent( + name="support", + system_message="Ask for an order ID.", +) + +llama_prompt = PromptTemplate("Explain {topic}.") +llama_tool = FunctionTool.from_defaults( + lookup_order, + name="llama_lookup", + description="Look up one order.", +) +llama_agent = ReActAgent.from_tools( + [lookup_order], + system_prompt="Use the support tools.", +) + +semantic_agent = ChatCompletionAgent( + name="support", + instructions="Ask for an order ID.", +) + + +@pydantic_agent.tool +def pydantic_lookup(order_id: str) -> str: + """Look up one order with Pydantic AI.""" + return order_id + + +@pydantic_agent.system_prompt +def pydantic_system_prompt() -> str: + return "Built at runtime." + + +@tool +def strands_lookup(order_id: str) -> str: + """Look up one order with Strands.""" + return order_id + + +@kernel_function(description="Look up one order with Semantic Kernel.") +def semantic_lookup(order_id: str) -> str: + return order_id diff --git a/internal/sync/adapter/python/testdata/imports/app/agent.py b/internal/sync/adapter/python/testdata/imports/app/agent.py new file mode 100644 index 000000000..18574dd8f --- /dev/null +++ b/internal/sync/adapter/python/testdata/imports/app/agent.py @@ -0,0 +1,8 @@ +from agents import Agent +from .prompts import SUPPORT_PROMPT + +support = Agent( + name="Support", + instructions=SUPPORT_PROMPT, + model="gpt-5", +) diff --git a/internal/sync/adapter/python/testdata/imports/app/prompts.py b/internal/sync/adapter/python/testdata/imports/app/prompts.py new file mode 100644 index 000000000..598d6533b --- /dev/null +++ b/internal/sync/adapter/python/testdata/imports/app/prompts.py @@ -0,0 +1 @@ +SUPPORT_PROMPT = "Ask for an order ID before discussing an order." diff --git a/internal/sync/adapter/python/testdata/langchain_prompts.py b/internal/sync/adapter/python/testdata/langchain_prompts.py new file mode 100644 index 000000000..8ad724f7a --- /dev/null +++ b/internal/sync/adapter/python/testdata/langchain_prompts.py @@ -0,0 +1,37 @@ +from langchain_core.messages import HumanMessage, SystemMessage +from langchain_core.prompts import ChatPromptTemplate, PromptTemplate +from langchain_core.tools import tool + +SHARED_MESSAGES = [ + ("system", "Use shared instructions."), + ("human", "{question}"), +] + +chat = ChatPromptTemplate.from_messages([ + ("system", "You route support requests."), + ("human", "Help with {request}."), +]) +shared_chat = ChatPromptTemplate.from_messages(SHARED_MESSAGES) + +chat_constructor = ChatPromptTemplate([ + SystemMessage("You summarize support cases."), + HumanMessage("Summarize {case}."), +]) + +simple = PromptTemplate.from_template("Explain {topic}.") +simple_keyword = PromptTemplate(template="Answer in one paragraph.") +chain = ChatPromptTemplate.from_messages([ + ("system", "Use the company policy."), + ("human", "{question}"), +]) | llm + +ambiguous = ( + ChatPromptTemplate.from_messages([("system", "First.")]) + | ChatPromptTemplate.from_messages([("system", "Second.")]) +) + + +@tool +def lookup_order(order_id: str) -> str: + """Look up one order and return its status.""" + return order_id diff --git a/internal/sync/adapter/python/testdata/messages.py b/internal/sync/adapter/python/testdata/messages.py new file mode 100644 index 000000000..32f2202e7 --- /dev/null +++ b/internal/sync/adapter/python/testdata/messages.py @@ -0,0 +1,11 @@ +from agents import Agent + +completion = Agent( + name="Completion", + messages=[ + {"role": "system", "content": "You answer support questions."}, + {"role": "user", "content": "Where is order {order_id}?"}, + {"role": "assistant", "content": "I will look up the order."}, + ], + model="gpt-5", +) diff --git a/internal/sync/adapter/python/testdata/settings.py b/internal/sync/adapter/python/testdata/settings.py new file mode 100644 index 000000000..c7021dbed --- /dev/null +++ b/internal/sync/adapter/python/testdata/settings.py @@ -0,0 +1,44 @@ +from dataclasses import dataclass +from enum import Enum + +from app.models import DEFAULT_MODEL + + +class Language(Enum): + EN = "en" + FR = "fr" + + +@dataclass +class SupportSettings: + instructions = """Ask for an order ID. +Keep the answer concise.""" + temperature = 0.2 + enabled = True + + +CONFIG = { + "agents": { + "support": { + "prompt": f"Use {DEFAULT_MODEL} for support.", + "model": "gpt-5", + }, + "refunds": { + "prompt": "Explain the refund policy.", + "model": "gpt-5", + }, + } +} + +PROMPTS = { + Language.EN: "Answer in English.", + Language.FR: "Answer in French.", +} + +MODELS = ["gpt-5", 'claude-sonnet'] + + +def build(): + prompt = "Function-local prompt." + retries = 3 + return prompt, retries