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