From bea1e3f71fbd92bb60a483683ef2d6b0c31019ce Mon Sep 17 00:00:00 2001 From: Cliff Tawiah Date: Fri, 30 Oct 2026 19:41:14 -0400 Subject: [PATCH] feat(sync): add TypeScript and JavaScript adapters --- .../reference/adapter_conformance_test.go | 16 +- internal/sync/reference/code.go | 23 + .../sync/reference/code_inspector_test.go | 102 ++ internal/sync/reference/defensive_test.go | 987 +++++++++++++++++- .../reference/framework_conformance_test.go | 12 - internal/sync/reference/import_test.go | 1 + internal/sync/reference/placeholder_test.go | 25 + internal/sync/reference/source_test.go | 593 ++++++++++- .../testdata/imports/typescript/src/agent.ts | 8 + .../imports/typescript/src/prompts.ts | 1 + .../reference/testdata/javascript/server.js | 12 + .../testdata/typescript/frameworks.ts | 90 ++ .../testdata/typescript/langchain.ts | 23 + .../reference/testdata/typescript/server.ts | 21 + internal/sync/reference/typescript.go | 724 +++++++++++++ .../sync/reference/typescript_frameworks.go | 210 ++++ 16 files changed, 2748 insertions(+), 100 deletions(-) create mode 100644 internal/sync/reference/testdata/imports/typescript/src/agent.ts create mode 100644 internal/sync/reference/testdata/imports/typescript/src/prompts.ts create mode 100644 internal/sync/reference/testdata/javascript/server.js create mode 100644 internal/sync/reference/testdata/typescript/frameworks.ts create mode 100644 internal/sync/reference/testdata/typescript/langchain.ts create mode 100644 internal/sync/reference/testdata/typescript/server.ts create mode 100644 internal/sync/reference/typescript.go create mode 100644 internal/sync/reference/typescript_frameworks.go diff --git a/internal/sync/reference/adapter_conformance_test.go b/internal/sync/reference/adapter_conformance_test.go index dbf6b8e7..fa223493 100644 --- a/internal/sync/reference/adapter_conformance_test.go +++ b/internal/sync/reference/adapter_conformance_test.go @@ -41,6 +41,18 @@ func TestRegisteredFormatsReadAndWriteRealisticSources(t *testing.T) { 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"}, @@ -87,10 +99,6 @@ func TestRegisteredFormatsReadAndWriteRealisticSources(t *testing.T) { } } -func sourceBundle(path string, data []byte) SourceBundle { - return SourceBundle{Primary: Document{Path: path, Data: data}} -} - func primaryText(t *testing.T, projection syncdomain.Projection) string { t.Helper() switch value := projection.(type) { diff --git a/internal/sync/reference/code.go b/internal/sync/reference/code.go index 62bb0cbb..2a5e528c 100644 --- a/internal/sync/reference/code.go +++ b/internal/sync/reference/code.go @@ -152,6 +152,29 @@ func encodePythonString(value, quote string) []byte { return []byte(quote + body + quote) } +func decodeTypeScriptString(raw []byte) (string, byte, error) { + if len(raw) < 2 || raw[0] != raw[len(raw)-1] { + return "", 0, fmt.Errorf("invalid TypeScript string") + } + quote := raw[0] + if quote != '\'' && quote != '"' && quote != '`' { + return "", 0, fmt.Errorf("unsupported TypeScript string") + } + body := string(raw[1 : len(raw)-1]) + if quote == '`' && strings.Contains(body, "${") { + return "", 0, fmt.Errorf("template interpolation is read-only") + } + value, err := decodeEscapes(body, false) + return value, quote, err +} + +func encodeTypeScriptString(value string, quote byte) ([]byte, error) { + if quote == '`' && strings.Contains(value, "${") { + return nil, fmt.Errorf("template interpolation syntax requires patch review") + } + return []byte(string(quote) + encodeEscapes(value, quote, false) + string(quote)), nil +} + func decodeEscapes(text string, python bool) (string, error) { var result strings.Builder for offset := 0; offset < len(text); { diff --git a/internal/sync/reference/code_inspector_test.go b/internal/sync/reference/code_inspector_test.go index 4e47e447..86c7ba1b 100644 --- a/internal/sync/reference/code_inspector_test.go +++ b/internal/sync/reference/code_inspector_test.go @@ -285,3 +285,105 @@ func TestCompletionMessagesEditContentButRejectStructure(t *testing.T) { assert.Equal(t, CapabilityReadOnly, result.Fields[syncdomain.FieldMessages].Capability) assert.Contains(t, result.Fields[syncdomain.FieldMessages].Reason, "add or remove messages by hand") } + +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 := ParseSource(sourceBundle(reference.File, source), id, test.mode, reference) + require.NoError(t, err) + assert.NotNil(t, projection) + }) + } +} + +func TestMutationAndSpreadStayLocalToTheirFields(t *testing.T) { + pythonSource := []byte(`PROMPT = "Initial" +PROMPT += " changed" +CONFIG = {"prompt": "Initial"} +CONFIG["prompt"] = "changed" +`) + pythonRoot, err := parseSourceDocument(Document{Path: "settings.py", Data: pythonSource}, "python") + require.NoError(t, err) + prompt, err := resolveSourcePath(pythonRoot, `constants[id="PROMPT"]`) + require.NoError(t, err) + assert.Equal(t, CapabilityReadOnly, prompt.capability) + assert.Contains(t, prompt.reason, "mutated") + config, err := resolveSourcePath(pythonRoot, `constants[id="CONFIG"]`) + require.NoError(t, err) + assert.Equal(t, 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 := ParseSource(sourceBundle(reference.File, typeScriptSource), id, "", reference) + require.NoError(t, err) + tool := projection.(syncdomain.ToolProjection) + assert.Equal(t, "Static description", *tool.Description) + schemaState, ok := syncdomain.ProjectionFieldState(tool, syncdomain.FieldSchema) + require.True(t, ok) + assert.Equal(t, syncdomain.CapabilityReadOnly, schemaState.Capability) + root, err := parseSourceDocument(Document{Path: "config.ts", Data: typeScriptSource}, "typescript") + require.NoError(t, err) + config, err = resolveSourcePath(root, `constants[id="CONFIG"]`) + require.NoError(t, err) + assert.Equal(t, CapabilityReadOnly, config.capability) +} + +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 := parseSourceDocument(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 := resolveSourcePath(root, test.path) + require.NoError(t, err) + assert.Equal(t, test.want, node.value) + } + }) + } +} diff --git a/internal/sync/reference/defensive_test.go b/internal/sync/reference/defensive_test.go index 7832f38e..c2479d02 100644 --- a/internal/sync/reference/defensive_test.go +++ b/internal/sync/reference/defensive_test.go @@ -1,72 +1,983 @@ package reference import ( + "math" + "strings" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + sitter "github.com/tree-sitter/go-tree-sitter" + pythonlanguage "github.com/tree-sitter/tree-sitter-python/bindings/go" + typescriptlanguage "github.com/tree-sitter/tree-sitter-typescript/bindings/go" + "gopkg.in/yaml.v3" syncdomain "github.com/launchdarkly/ldcli/internal/sync" ) -func TestStructuredAdaptersRejectMalformedSources(t *testing.T) { +func TestStringCodecsHandleSupportedEscapes(t *testing.T) { + python := []byte(`"\\\'\"\n\r\t\b\f\v\x41\u0042\U0001F600"`) + value, _, err := decodePythonString(python) + require.NoError(t, err) + assert.Equal(t, "\\'\"\n\r\t\b\f\vAB😀", value) + + typescript := []byte(`"\\\'\"\n\r\t\b\f\v\x41\u0042"`) + value, _, err = decodeTypeScriptString(typescript) + require.NoError(t, err) + assert.Equal(t, "\\'\"\n\r\t\b\f\vAB", value) + + for name, input := range map[string][]byte{ + "short": {'"'}, + "bad delimiter": []byte(`x`), + "template expression": []byte("`hello ${name}`"), + "trailing slash": []byte(`"hello\"`), + "bad hex": []byte(`"\xGG"`), + "TypeScript U escape": []byte(`"\U00000041"`), + } { + t.Run(name, func(t *testing.T) { + if input[0] == '`' || name == "TypeScript U escape" { + _, _, err := decodeTypeScriptString(input) + require.Error(t, err) + return + } + _, _, err := decodePythonString(input) + require.Error(t, err) + }) + } +} + +func TestStringCodecsPreserveQuotesControlsAndMultilineText(t *testing.T) { + assert.Equal(t, "\"\"\"line\n\\\"\\\"\\\"end\"\"\"", string(encodePythonString("line\n\"\"\"end", `"""`))) + encoded, err := encodeTypeScriptString("line\n\t\r\x01\"", '"') + require.NoError(t, err) + assert.Equal(t, `"line\n\t\r\u0001\""`, string(encoded)) + _, err = encodeTypeScriptString("unsafe ${value}", '`') + require.Error(t, err) + + for name, input := range map[string][]byte{ + "raw prefix": []byte(`r"text"`), + "byte prefix": []byte(`b"text"`), + "formatted prefix": []byte(`f"text"`), + "unknown prefix": []byte(`q"text"`), + "unterminated triple": []byte(`"""text"`), + "short hex": []byte(`"\x1"`), + "invalid Unicode": []byte(`"\uD800"`), + "large Unicode": []byte(`"\UFFFFFFFF"`), + } { + t.Run(name, func(t *testing.T) { + _, _, err := decodePythonString(input) + require.Error(t, err) + }) + } +} + +func TestPlaceholderTranslationRejectsAmbiguousForms(t *testing.T) { + tests := []struct { + name string + value string + style placeholderStyle + from bool + }{ + {name: "brace escaped", value: "{{name}}", style: bracePlaceholder}, + {name: "brace residue", value: "literal }", style: bracePlaceholder}, + {name: "source unclosed", value: "{name", style: bracePlaceholder}, + {name: "source bad name", value: "{1name}", style: bracePlaceholder}, + {name: "dollar unclosed", value: "${name", style: dollarPlaceholder}, + {name: "target residue", value: "literal {", style: bracePlaceholder, from: true}, + {name: "target unclosed", value: "{{name", style: bracePlaceholder, from: true}, + {name: "target bad name", value: "{{1name}}", style: bracePlaceholder, from: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var err error + if test.from { + _, err = placeholdersFromLaunchDarkly(test.value, test.style) + } else { + _, err = placeholdersToLaunchDarkly(test.value, test.style) + } + require.Error(t, err) + }) + } + + node := stringNode("hello {name}", 0, 14, func(text string) ([]byte, error) { return []byte(text), nil }) + translated := translatePlaceholderNode(node, bracePlaceholder) + _, err := translated.encode(1) + require.Error(t, err) + _, err = translated.encode("hello {{1name}}") + require.Error(t, err) + assert.Nil(t, translatePlaceholderNode(nil, bracePlaceholder)) + + dynamic := &sourceNode{value: true} + assert.Same(t, dynamic, translatePlaceholderNode(dynamic, bracePlaceholder)) + assert.Same(t, dynamic, translateMessagePlaceholders(dynamic, bracePlaceholder)) +} + +func TestPathParserRejectsEveryUnsupportedShape(t *testing.T) { + paths := []string{ + `[id="one"]`, + `$["key"]`, + `items[id`, + `items[id!`, + `items[id="one"][name="two"]`, + `items[id="one",name=two]`, + `items[id="one",name]`, + `items[id="one",="two"]`, + `items[id="one",name="two"`, + `items[id="\uD800"]`, + `items[id="\uDC00"]`, + `items[id="\uD800\u0041"]`, + `items[id="bad\q"]`, + `["unclosed]`, + `items[id="one"] trailing`, + } + for _, path := range paths { + t.Run(path, func(t *testing.T) { + err := ParsePath(path) + require.Error(t, err) + problem, ok := syncdomain.ProblemFrom(err) + require.True(t, ok) + assert.Equal(t, syncdomain.ErrorInvalidPathSyntax, problem.Code) + }) + } + require.NoError(t, ParsePath(`items[id="\uD83D\uDE00"]`)) + assert.Error(t, pathSyntaxError("x", 10, "test")) +} + +func TestSourcePathFailuresUseStableCodes(t *testing.T) { + root := &sourceNode{fields: map[string]*sourceNode{ + "scalar": {value: "text"}, + "items": {items: []*sourceNode{ + {fields: map[string]*sourceNode{"id": {value: 10}}}, + }}, + }} tests := []struct { - name, format, source string + name, path string + code syncdomain.ErrorCode }{ - {name: "JSON duplicate", format: "json", source: `{"key":1,"key":2}`}, - {name: "JSON trailing", format: "json", source: `{"key":1} trailing`}, - {name: "YAML duplicate", format: "yaml", source: "key: one\nkey: two\n"}, - {name: "TOML duplicate", format: "toml", source: "key = 1\nkey = 2\n"}, + {name: "missing field", path: "missing", code: syncdomain.ErrorPathNotFound}, + {name: "field on scalar", path: "scalar.child", code: syncdomain.ErrorPathTypeMismatch}, + {name: "filter type", path: `items[id="10"]`, code: syncdomain.ErrorFilterTypeMismatch}, + {name: "no filter match", path: `items[name="missing"]`, code: syncdomain.ErrorPathNotFound}, } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := resolveSourcePath(root, test.path) + problem, ok := syncdomain.ProblemFrom(err) + require.True(t, ok) + assert.Equal(t, test.code, problem.Code) + }) + } +} + +func TestProjectionValidationCoversEveryResourceKind(t *testing.T) { + _, err := 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 = 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 := UpdateSource( + sourceBundle(reference.File, []byte(`{"prompt":"Help"}`)), + id, syncdomain.VariationModeAgent, reference, + syncdomain.ToolProjection{}, + ) + require.ErrorContains(t, err, "projection kind") + + _, err = UpdateSource( + sourceBundle(reference.File, []byte(`{"prompt":"Help"}`)), + id, syncdomain.VariationModeAgent, reference, + syncdomain.VariationProjection{Mode: syncdomain.VariationModeAgent, Instructions: stringPointerForReference("Next")}, + ) + problem, ok := syncdomain.ProblemFrom(err) + require.True(t, ok) + assert.Equal(t, syncdomain.ErrorPathNotFound, problem.Code) +} + +func TestBuildProjectionAcceptsAllMappedFieldTypes(t *testing.T) { + selected := &sourceNode{fields: map[string]*sourceNode{ + "prompt": {value: "Help"}, + "messages": {value: []syncdomain.Message{{Role: syncdomain.RoleSystem, Content: "Help"}}}, + "name": {value: "assistant"}, + "model": {value: "model"}, + "temperature": {value: int64(1)}, + "tools": {value: []any{"search", "read"}}, + "skills": {value: []string{"review"}}, + }} + fieldMap := map[string]string{ + string(syncdomain.FieldPrompt): "prompt", + string(syncdomain.FieldName): "name", + string(syncdomain.FieldModel): "model", + `model.parameters["temperature"]`: "temperature", + string(syncdomain.FieldTools): "tools", + string(syncdomain.FieldSkills): "skills", + } + projection, err := buildProjection(syncdomain.KindVariation, "", selected, fieldMap) + require.NoError(t, err) + variation := projection.(syncdomain.VariationProjection) + assert.Equal(t, syncdomain.VariationModeAgent, variation.Mode) + assert.Equal(t, 1.0, (*variation.Parameters)["temperature"]) + assert.Equal(t, []string{"search", "read"}, *variation.Tools) + + projection, err = buildProjection( + syncdomain.KindVariation, syncdomain.VariationModeCompletion, selected, + map[string]string{string(syncdomain.FieldMessages): "messages"}, + ) + require.NoError(t, err) + assert.NotNil(t, projection.(syncdomain.VariationProjection).Messages) + + selected = &sourceNode{fields: map[string]*sourceNode{ + "description": {value: "Search"}, + "schema": {value: map[string]any{"type": "object"}}, + "parameters": {value: map[string]any{"limit": 10}}, + "tags": {value: []any{"search", "safe"}}, + }} + projection, err = buildProjection(syncdomain.KindTool, "", selected, map[string]string{ + string(syncdomain.FieldDescription): "description", + string(syncdomain.FieldSchema): "schema", + string(syncdomain.FieldCustomParameters): "parameters", + string(syncdomain.FieldTags): "tags", + }) + require.NoError(t, err) + assert.Equal(t, []string{"search", "safe"}, *projection.(syncdomain.ToolProjection).Tags) + + selected = &sourceNode{fields: map[string]*sourceNode{ + "description": {value: "Review"}, + "markdown": {value: "# Review"}, + }} + projection, err = buildProjection(syncdomain.KindSkill, "", selected, map[string]string{ + string(syncdomain.FieldDescription): "description", + string(syncdomain.FieldMarkdown): "markdown", + }) + require.NoError(t, err) + assert.Equal(t, "# Review", *projection.(syncdomain.SkillProjection).Markdown) +} + +func TestBuildProjectionRejectsInvalidMappedFieldTypes(t *testing.T) { + tests := []struct { + name string + kind syncdomain.Kind + mode syncdomain.VariationMode + field syncdomain.Field + value any + }{ + {name: "agent prompt", kind: syncdomain.KindVariation, field: syncdomain.FieldPrompt, value: true}, + {name: "messages", kind: syncdomain.KindVariation, mode: syncdomain.VariationModeCompletion, field: syncdomain.FieldMessages, value: true}, + {name: "message role", kind: syncdomain.KindVariation, mode: syncdomain.VariationModeCompletion, field: syncdomain.FieldMessages, value: []syncdomain.Message{{Role: "invalid"}}}, + {name: "name", kind: syncdomain.KindVariation, field: syncdomain.FieldName, value: true}, + {name: "model", kind: syncdomain.KindVariation, field: syncdomain.FieldModel, value: true}, + {name: "parameters", kind: syncdomain.KindVariation, field: syncdomain.FieldModelParameters, value: "hot"}, + {name: "tools", kind: syncdomain.KindVariation, field: syncdomain.FieldTools, value: []any{"ok", 1}}, + {name: "skills", kind: syncdomain.KindVariation, field: syncdomain.FieldSkills, value: true}, + {name: "description", kind: syncdomain.KindTool, field: syncdomain.FieldDescription, value: true}, + {name: "schema", kind: syncdomain.KindTool, field: syncdomain.FieldSchema, value: true}, + {name: "parameters", kind: syncdomain.KindTool, field: syncdomain.FieldCustomParameters, value: true}, + {name: "tags", kind: syncdomain.KindTool, field: syncdomain.FieldTags, value: []any{"ok", 1}}, + {name: "skill", kind: syncdomain.KindSkill, field: syncdomain.FieldMarkdown, value: true}, + } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - _, err := ParseSource( - sourceBundle("source", []byte(test.source)), - id, - syncdomain.VariationModeAgent, - syncdomain.Reference{ - File: "source", Format: test.format, Select: "$", - Map: map[string]string{"prompt": "key"}, - }, - ) + selected := &sourceNode{fields: map[string]*sourceNode{ + string(test.field): {value: test.value}, + }} + fieldMap := map[string]string{string(test.field): string(test.field)} + if test.kind == syncdomain.KindVariation && test.field != syncdomain.FieldPrompt && test.field != syncdomain.FieldMessages { + selected.fields["prompt"] = &sourceNode{value: "Help"} + fieldMap[string(syncdomain.FieldPrompt)] = "prompt" + } + _, err := buildProjection(test.kind, test.mode, selected, fieldMap) require.Error(t, err) }) } + + _, err := buildProjection(syncdomain.KindVariation, "", &sourceNode{}, map[string]string{}) + require.Error(t, err) + _, err = buildProjection(syncdomain.Kind("unknown"), "", &sourceNode{}, nil) + require.Error(t, err) +} + +func TestProjectionFieldReadsEveryOptionalField(t *testing.T) { + text := "value" + list := []string{"one"} + messages := []syncdomain.Message{{Role: syncdomain.RoleSystem, Content: "Help"}} + object := map[string]any{"type": "object"} + parameters := map[string]any{"temperature": 0.5} + projections := []syncdomain.Projection{ + syncdomain.VariationProjection{ + Instructions: &text, Name: &text, Model: &text, Parameters: ¶meters, Tools: &list, Skills: &list, + }, + syncdomain.VariationProjection{Messages: &messages}, + syncdomain.ToolProjection{ + Description: &text, Schema: &object, CustomParameters: &object, Tags: &list, + }, + syncdomain.SkillProjection{Description: &text, Markdown: &text}, + } + fields := [][]syncdomain.Field{ + {syncdomain.FieldPrompt, syncdomain.FieldName, syncdomain.FieldModel, syncdomain.FieldModelParameters, syncdomain.FieldTools, syncdomain.FieldSkills}, + {syncdomain.FieldMessages}, + {syncdomain.FieldDescription, syncdomain.FieldSchema, syncdomain.FieldCustomParameters, syncdomain.FieldTags}, + {syncdomain.FieldDescription, syncdomain.FieldMarkdown}, + } + for index, projection := range projections { + for _, field := range fields[index] { + _, present, err := projectionField(projection, field) + require.NoError(t, err) + assert.True(t, present) + } + } } -func TestMarkdownProfilesRejectUnsafeMarkers(t *testing.T) { - _, err := parseMarkdownProfile( - Document{Path: "instructions.md", Data: []byte("\n")}, - false, +func TestMessageUpdateSafetyBranches(t *testing.T) { + have := []syncdomain.Message{{Role: syncdomain.RoleSystem, Content: "old"}} + tests := []struct { + name string + node *sourceNode + want []syncdomain.Message + }{ + {name: "different count", node: &sourceNode{}, want: nil}, + {name: "different role", node: &sourceNode{}, want: []syncdomain.Message{{Role: syncdomain.RoleUser, Content: "old"}}}, + {name: "missing content", node: &sourceNode{}, want: []syncdomain.Message{{Role: syncdomain.RoleSystem, Content: "new"}}}, + {name: "read only content", node: &sourceNode{items: []*sourceNode{{fields: map[string]*sourceNode{"content": {reason: "dynamic"}}}}}, want: []syncdomain.Message{{Role: syncdomain.RoleSystem, Content: "new"}}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + update := updateMessageContents(SourceBundle{}, test.node, have, test.want) + assert.Equal(t, CapabilityReadOnly, update.Capability) + }) + } + + content := stringNode("old", 0, 5, func(string) ([]byte, error) { return []byte(`"new"`), nil }) + content.file = "missing.py" + update := updateMessageContents( + SourceBundle{Primary: Document{Path: "main.py"}}, + &sourceNode{items: []*sourceNode{{fields: map[string]*sourceNode{"content": content}}}}, + have, []syncdomain.Message{{Role: syncdomain.RoleSystem, Content: "new"}}, ) + assert.Equal(t, CapabilityReadOnly, update.Capability) +} + +func TestSourceValueHelpersRejectDynamicValues(t *testing.T) { + assert.False(t, semanticEqual(make(chan int), make(chan int))) + _, ok := sourceNumber(math.NaN()) + assert.True(t, ok) + _, ok = sourceNumber("1") + assert.False(t, ok) + _, ok = stringList(true) + assert.False(t, ok) + _, ok = objectValue(true) + assert.False(t, ok) + + stringValue := stringNode("old", 0, 3, func(value string) ([]byte, error) { return []byte(value), nil }) + _, err := stringValue.encode(1) + require.Error(t, err) + numberValue := numberNode(1, 0, 1, func(float64) ([]byte, error) { return []byte("1"), nil }) + _, err = numberValue.encode("one") require.Error(t, err) } -func TestPlaceholderTranslationRejectsAmbiguousForms(t *testing.T) { - for _, value := range []string{"{{name}}", "{name", "{1name}", "literal }"} { - _, err := placeholdersToLaunchDarkly(value, bracePlaceholder) - require.Error(t, err) +func TestPythonInspectorHandlesStaticAndDynamicLanguageForms(t *testing.T) { + source := []byte(` +from langchain_core.prompts import PromptTemplate, ChatPromptTemplate +from langchain_core.tools import tool +from fastmcp import FastMCP +from .values import TEXT, MISSING as missing + +TRUE = True +FALSE = False +NUMBER = 1.5 +TUPLE = ("system", TEXT) +OBJECT = {"text": TEXT, 1: "number", **{"extra": "value"}} +DYNAMIC = [TEXT, make_value()] +MUTATED = ["one"] +MUTATED.append("two") +MUTATED[0] = "changed" + +empty = PromptTemplate.from_template() +chat = ChatPromptTemplate.from_messages([("human", TEXT), ("ai", "Done"), {"role": "system"}]) + +@tool +def search(query: str): + """Search docs.""" + return query + +mcp = FastMCP("server") +@mcp.tool(description="Run a task") +def run_task(value: str): + """Run.""" + return value + +@mcp.prompt() +def dynamic_prompt(name: str): + """Prompt docs.""" + return f"Hello {name}" + +class Scope: + VALUE = "scoped" +`) + root, err := parsePythonBundle(SourceBundle{ + Primary: Document{Path: "src/main.py", Data: source}, + Dependencies: map[string]Document{ + "src/values.py": {Path: "src/values.py", Data: []byte(`TEXT = "Hello {name}"`)}, + }, + }) + require.NoError(t, err) + require.NotNil(t, root) + assert.NotNil(t, root.fields["tools"]) + assert.NotNil(t, root.fields["prompts"]) + + dependencies, err := pythonAdapter{}.Dependencies(Document{Path: "src/main.py", Data: source}) + require.NoError(t, err) + assert.Contains(t, dependencies, "src/values.py") +} + +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(SourceBundle{ + Primary: Document{Path: "src/main.ts", Data: source}, + Dependencies: map[string]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( + Document{Path: "src/main.ts", Data: source}, + ) + require.NoError(t, err) + assert.Contains(t, dependencies, "src/values.ts") + + jsRoot, err := parseTypeScriptBundle(SourceBundle{ + Primary: Document{Path: "src/main.js", Data: []byte(`const value = {enabled: false};`)}, + }, false) + require.NoError(t, err) + require.NotNil(t, jsRoot) +} + +func TestJSONAdapterRejectsMalformedSources(t *testing.T) { + inputs := []string{ + "", + `{"key" "value"}`, + `{"key": true "next": false}`, + `{"key": 1, "key": 2}`, + `[1 2]`, + `{"key":}`, + `{"key":"unterminated}`, + "{\"key\":\"control\x01\"}", + `truth`, + `+`, + `1e`, + `true trailing`, } - for _, value := range []string{"{{name", "{{1name}}", "literal {"} { - _, err := placeholdersFromLaunchDarkly(value, bracePlaceholder) + for _, input := range inputs { + t.Run(input, func(t *testing.T) { + _, err := parseJSONDocument(Document{Path: "value.json", Data: []byte(input)}) + require.Error(t, err) + }) + } + _, err := parseJSONDocument(Document{Path: "value.json", Data: []byte{0xff}}) + require.Error(t, err) + + root, err := parseJSONDocument(Document{Path: "value.json", Data: []byte("\ufeff {\"empty\":{},\"array\":[],\"false\":false,\"null\":null}")}) + require.NoError(t, err) + assert.Equal(t, false, root.fields["false"].value) +} + +func TestYAMLAdapterHandlesSafetyForms(t *testing.T) { + for name, input := range map[string][]byte{ + "invalid UTF-8": {0xff}, + "invalid syntax": []byte("value: ["), + "empty": nil, + "non-string key": []byte("1: value"), + "duplicate": []byte("key: one\nkey: two\n"), + } { + t.Run(name, func(t *testing.T) { + _, err := parseYAMLDocument(Document{Path: "value.yaml", Data: input}) + require.Error(t, err) + }) + } + + root, err := parseYAMLDocument(Document{Path: "value.yaml", Data: []byte(` +defaults: &defaults + enabled: true + count: 1_000 +copy: + <<: *defaults +tagged: !custom value +block: | + content +sequence: &items [one, two] +null_value: null +--- +second: document +`)}) + require.NoError(t, err) + assert.Equal(t, CapabilityReadOnly, root.capability) + assert.Equal(t, CapabilityReadOnly, root.fields["copy"].capability) + assert.Equal(t, CapabilityReadOnly, root.fields["tagged"].capability) +} + +func TestTOMLAdapterHandlesSafetyForms(t *testing.T) { + for name, input := range map[string][]byte{ + "invalid UTF-8": {0xff}, + "invalid syntax": []byte("value = ["), + "duplicate": []byte("value = 1\nvalue = 2\n"), + "value as table": []byte("value = 1\n[value]\nchild = 2\n"), + } { + t.Run(name, func(t *testing.T) { + _, err := parseTOMLDocument(Document{Path: "value.toml", Data: input}) + require.Error(t, err) + }) + } + + root, err := parseTOMLDocument(Document{Path: "value.toml", Data: []byte("\ufeff" + ` +basic = "text" +literal = 'text' +multiline_basic = """text""" +multiline_literal = '''text''' +enabled = true +integer = 10 +float = 1.5 +date = 2026-10-30 +array = [1, # comment + 2] +inline = { name = "value" } +[[tools]] +name = "one" +[[tools]] +name = "two" +`)}) + require.NoError(t, err) + assert.Equal(t, "2026-10-30", root.fields["date"].value) + assert.Len(t, root.fields["tools"].items, 2) + + assert.Equal(t, `"""next\"""value"""`, mustTOMLString(t, `next"""value`, `"""old"""`)) + assert.Equal(t, "'''next'''", mustTOMLString(t, "next", "'''old'''")) + _, err = encodeTOMLString("bad'''value", "'''old'''") + require.Error(t, err) + _, err = encodeTOMLString("bad\nvalue", "'old'") + require.Error(t, err) + encoded := encodeTOMLBasicString("\b\t\n\f\r\x01\u0080\"\\") + assert.True(t, strings.HasPrefix(encoded, `"`)) +} + +func TestMarkdownProfilesRejectUnsafeMarkersAndFrontMatter(t *testing.T) { + inputs := []string{ + "\n", + "", + "", + } + for _, input := range inputs { + _, err := parseMarkdownProfile(Document{Path: "AGENTS.md", Data: []byte(input)}, false) require.Error(t, err) } + _, err := parseMarkdownProfile(Document{Path: "rule.md", Data: []byte("---\ninvalid: [\n---\nbody")}, true) + require.Error(t, err) + + root, err := parseMarkdownProfile(Document{Path: "AGENTS.md", Data: []byte("\ufeff# Instructions")}, false) + require.NoError(t, err) + assert.Equal(t, "# Instructions", root.fields["body"].value) +} + +func TestMarkdownAndAgentSkillSafetyForms(t *testing.T) { + selectPath, fieldMap := markdownAdapter{}.Defaults(syncdomain.KindTool) + assert.Equal(t, "$", selectPath) + assert.Nil(t, fieldMap) + selectPath, fieldMap = agentSkillAdapter{}.Defaults(syncdomain.KindVariation) + assert.Equal(t, "$", selectPath) + assert.Nil(t, fieldMap) + + _, err := parseMarkdown(Document{Path: "README.md", Data: []byte{0xff}}) + require.Error(t, err) + _, err = parseAgentSkill(Document{Path: "SKILL.md", Data: []byte{0xff}}) + require.Error(t, err) + + for name, input := range map[string]string{ + "missing front matter": "# Body", + "unclosed front matter": "---\ndescription: value", + "invalid YAML": "---\ndescription: [\n---\nBody", + "front matter scalar": "---\nvalue\n---\nBody", + "missing description": "---\nname: skill\n---\nBody", + "description type": "---\ndescription: [value]\n---\nBody", + "empty body": "---\ndescription: value\n---\n", + } { + t.Run(name, func(t *testing.T) { + _, err := parseAgentSkill(Document{Path: "SKILL.md", Data: []byte(input)}) + require.Error(t, err) + }) + } + + root, err := parseAgentSkill(Document{Path: "SKILL.md", Data: []byte("---\ndescription: |\n long text\n---\nBody\n")}) + require.NoError(t, err) + assert.Equal(t, CapabilityReadOnly, root.fields["frontmatter"].fields["description"].capability) + + sections := markdownSections([]byte(" # indented\n````\n# hidden\n````\n## Visible ##\nbody\n")) + require.Len(t, sections, 1) + assert.Equal(t, "Visible", sections[0].fields["heading"].value) + + _, _, _, err = yamlScalarSpan(frontMatterRegion{}, &yaml.Node{}) + require.Error(t, err) + assert.Equal(t, 0, yamlTokenEnd(nil, 0)) + assert.Equal(t, `"line\nnext"`, quoteYAML("line\nnext", "'old'")) + assert.Equal(t, "'it''s'", quoteYAML("it's", "'old'")) + _, err = encodeYAMLString("bad\x01", "old") + require.Error(t, err) + + exact := exactMarkdownNode([]byte("body\n"), 0, 5) + encoded, err := exact.encode("next") + require.NoError(t, err) + assert.Equal(t, "next\n", string(encoded)) + + region, bodyStart, err := splitFrontMatter([]byte("\ufeff---\r\ndescription: value\r\n---\r\n\r\nBody")) + require.NoError(t, err) + assert.NotEmpty(t, region.data) + assert.Greater(t, bodyStart, 0) + assert.Equal(t, len(`"unterminated`), yamlTokenEnd([]byte(`"unterminated`), 0)) + assert.Equal(t, len(`'it''s'`), yamlTokenEnd([]byte(`'it''s'`), 0)) + assert.Equal(t, 5, yamlTokenEnd([]byte("value, next"), 0)) +} + +func TestRegistryContractAndDependencyErrors(t *testing.T) { + assert.Nil(t, NewInspection(nil).root) + inspection := NewInspection(&Node{ + Value: "root", + Fields: map[string]*Node{"child": {Value: "value"}}, + Items: []*Node{{Value: "item"}}, + }) + require.Equal(t, "value", inspection.root.fields["child"].value) + require.Len(t, inspection.root.items, 1) + + require.Error(t, Register(nil)) + require.Error(t, Register(contractAdapter{})) + require.Error(t, Register(jsonAdapter{})) + + _, err := registeredAdapter("missing-format") + require.Error(t, err) + dependencies, err := DependencyPaths("json", Document{}) + require.NoError(t, err) + assert.Nil(t, dependencies) + _, err = DependencyPaths("missing-format", Document{}) + require.Error(t, err) +} + +func TestCodeParsersRejectUnsafeInput(t *testing.T) { + _, err := pythonAdapter{}.Dependencies(Document{Path: "main.py", Data: []byte{0xff}}) + require.Error(t, err) + _, err = pythonAdapter{}.Dependencies(Document{Path: "main.py", Data: make([]byte, maxSourceBytes+1)}) + require.Error(t, err) + _, err = pythonAdapter{}.Dependencies(Document{Path: "main.py", Data: []byte("def broken(")}) + require.Error(t, err) + + result := messageListFromCollection(&sourceNode{items: []*sourceNode{ + {items: []*sourceNode{{value: "human"}, {value: "Hello"}}}, + {fields: map[string]*sourceNode{"role": {value: "system"}, "content": {value: true}}}, + }}) + assert.Equal(t, CapabilityReadOnly, result.capability) + assert.Nil(t, messageListFromCollection(nil)) + + _, _, err = decodeTypeScriptString([]byte("xx")) + require.Error(t, err) + _, _, err = decodeTypeScriptString([]byte(`"x'`)) + require.Error(t, err) + _, err = decodeEscapes("\\\nnext", false) + require.NoError(t, err) + _, err = decodeEscapes(`\q`, false) + require.Error(t, err) +} + +func TestPythonValueSafetyBranches(t *testing.T) { + document := Document{Path: "main.py", Data: []byte(` +TRUE = True +FALSE = False +OVERFLOW = 1e9999 +DICT = {"static": "value", "dynamic": make_value()} +LIST = ["value", make_value()] +message = AIMessage(content="done") +later = UNKNOWN +`)} + tree, err := parseTree(document, sitter.NewLanguage(pythonlanguage.Language())) + require.NoError(t, err) + defer tree.Close() + + trueNode := findNodeByText(tree.RootNode(), document.Data, "true", "True") + require.NotNil(t, trueNode) + value := pythonValue(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 := findNodeByText(tree.RootNode(), document.Data, "float", "1e9999") + require.NotNil(t, overflow) + assert.Equal(t, CapabilityReadOnly, pythonValue(overflow, document.Data, nil).capability) + + dictionary := findNodeByText(tree.RootNode(), document.Data, "dictionary", `{"static": "value", "dynamic": make_value()}`) + require.NotNil(t, dictionary) + assert.Equal(t, CapabilityReadOnly, pythonDictionary(dictionary, document.Data, nil).capability) + list := findNodeByText(tree.RootNode(), document.Data, "list", `["value", make_value()]`) + require.NotNil(t, list) + assert.Equal(t, CapabilityReadOnly, pythonSequence(list, document.Data, nil).capability) + + call := findNodeByText(tree.RootNode(), document.Data, "call", `AIMessage(content="done")`) + require.NotNil(t, call) + message := pythonMessage(call, document.Data, nil) + assert.Equal(t, syncdomain.RoleAssistant, message.fields["role"].value) + assert.Equal(t, "done", message.fields["content"].value) + + identifier := findNodeByText(tree.RootNode(), document.Data, "identifier", "UNKNOWN") + require.NotNil(t, identifier) + wildcard := &sourceNode{reason: "unresolved", code: syncdomain.ErrorImportUnresolved} + resolved := pythonValue(identifier, document.Data, map[string]*sourceNode{"*": wildcard}) + assert.Contains(t, resolved.reason, "star import") + + assert.Equal(t, "module.py", pythonModulePaths("main.py", "module")[0]) + assert.Empty(t, pythonMutationRoot(nil, nil)) + assert.Equal(t, CapabilityReadOnly, pythonMessages(nil, nil, nil).capability) } -func TestPythonAdapterRejectsDynamicPrompt(t *testing.T) { +func TestTypeScriptValueSafetyBranches(t *testing.T) { + document := 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 := parseTree(document, sitter.NewLanguage(typescriptlanguage.LanguageTypescript())) + require.NoError(t, err) + defer tree.Close() + + trueNode := findNodeByText(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 := findNodeByText(tree.RootNode(), document.Data, "number", "1e9999") + require.NotNil(t, overflow) + assert.Equal(t, CapabilityReadOnly, typeScriptValue(overflow, document.Data, nil).capability) + template := findNodeByText(tree.RootNode(), document.Data, "template_string", "`hello ${name}`") + require.NotNil(t, template) + assert.Equal(t, CapabilityReadOnly, typeScriptValue(template, document.Data, nil).capability) + + object := findNodeByText(tree.RootNode(), document.Data, "object", `{ "name": "one", "name": "two", ...dynamic }`) + require.NotNil(t, object) + assert.Equal(t, CapabilityReadOnly, typeScriptObject(object, document.Data, nil).capability) + dynamicObject := findNodeByText(tree.RootNode(), document.Data, "object", `{ static: "value", ...dynamic }`) + require.NotNil(t, dynamicObject) + assert.Equal(t, CapabilityReadOnly, typeScriptObject(dynamicObject, document.Data, nil).capability) + array := findNodeByText(tree.RootNode(), document.Data, "array", `["value", makeValue()]`) + require.NotNil(t, array) + assert.Equal(t, CapabilityReadOnly, typeScriptValue(array, document.Data, nil).capability) + + member := findNodeByText(tree.RootNode(), document.Data, "member_expression", `values.TEXT`) + require.NotNil(t, member) + namespace := &sourceNode{fields: map[string]*sourceNode{"TEXT": {value: "resolved"}}} + assert.Equal(t, "resolved", typeScriptValue(member, document.Data, map[string]*sourceNode{"values": namespace}).value) + identifier := findNodeByText(tree.RootNode(), document.Data, "identifier", "STATIC_VALUE") + require.NotNil(t, identifier) + assert.Equal(t, "resolved", typeScriptValue(identifier, document.Data, map[string]*sourceNode{ + "STATIC_VALUE": {value: "resolved", end: 1}, + }).value) + + assert.Equal(t, CapabilityReadOnly, typeScriptObject(nil, nil, nil).capability) + assert.Equal(t, CapabilityReadOnly, typeScriptMessages(nil, nil, nil).capability) + assert.Equal(t, CapabilityReadOnly, typeScriptMessages(object, document.Data, nil).capability) + messageObject := findNodeByText(tree.RootNode(), document.Data, "object", `{}`) + require.NotNil(t, messageObject) + message := typeScriptMessage(messageObject, document.Data, nil) + assert.Equal(t, CapabilityReadOnly, message.fields["role"].capability) + assert.Equal(t, CapabilityReadOnly, message.fields["content"].capability) + _, ok := typeScriptPropertyName(nil, nil) + assert.False(t, ok) + assert.Empty(t, typeScriptMutationRoot(nil, nil)) +} + +func TestSourceEntryPointsPreserveStructuredFailures(t *testing.T) { 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 := ParseSource( - sourceBundle("agent.py", []byte("from agents import Agent\nsupport = Agent(instructions=load_prompt())\n")), - id, - syncdomain.VariationModeAgent, - reference, + _, err := NormalizeReference(id, syncdomain.Reference{ + File: "value.txt", Format: "missing", Select: "$", + Map: map[string]string{string(syncdomain.FieldPrompt): "$"}, + }) + require.Error(t, err) + _, err = NormalizeReference(id, syncdomain.Reference{ + File: "value.json", Format: "json", Select: "$", Map: map[string]string{}, + }) + require.Error(t, err) + _, err = NormalizeReference(id, syncdomain.Reference{ + File: "value.json", Format: "json", Select: "$", + Map: map[string]string{string(syncdomain.FieldPrompt): "invalid["}, + }) + require.Error(t, err) + _, err = NormalizeReference(id, syncdomain.Reference{ + File: "main.py", Format: "python", + Map: map[string]string{string(syncdomain.FieldPrompt): "template"}, + }) + require.Error(t, err) + _, err = ParseSource(SourceBundle{}, id, syncdomain.VariationModeAgent, syncdomain.Reference{ + File: "value.json", Format: "missing", Select: "$", + Map: map[string]string{string(syncdomain.FieldPrompt): "$"}, + }) + require.Error(t, err) + _, err = ParseSource(sourceBundle("value.json", []byte(`{"prompt":"Help"}`)), id, syncdomain.VariationModeAgent, syncdomain.Reference{ + File: "value.json", Format: "json", Select: "missing", + Map: map[string]string{string(syncdomain.FieldPrompt): "prompt"}, + }) + require.Error(t, err) + _, err = ParseSource(sourceBundle("value.json", []byte(`{"prompt":`)), id, syncdomain.VariationModeAgent, syncdomain.Reference{ + File: "value.json", Format: "json", Select: "$", + Map: map[string]string{string(syncdomain.FieldPrompt): "prompt"}, + }) + require.Error(t, err) + _, err = UpdateSource( + SourceBundle{}, id, syncdomain.VariationModeAgent, + syncdomain.Reference{ + File: "value.json", Format: "missing", Select: "$", + Map: map[string]string{string(syncdomain.FieldPrompt): "$"}, + }, + syncdomain.VariationProjection{}, + ) + require.Error(t, err) + _, err = UpdateSource( + sourceBundle("value.json", []byte(`{"prompt":`)), id, syncdomain.VariationModeAgent, + syncdomain.Reference{ + File: "value.json", Format: "json", Select: "$", + Map: map[string]string{string(syncdomain.FieldPrompt): "prompt"}, + }, + syncdomain.VariationProjection{}, + ) + require.Error(t, err) + _, err = UpdateSource( + sourceBundle("value.json", []byte(`{"prompt":"Help"}`)), id, syncdomain.VariationModeAgent, + 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 := UpdateSource( + sourceBundle("value.json", []byte(`{"prompt":"Help"}`)), + id, syncdomain.VariationModeAgent, + syncdomain.Reference{ + File: "value.json", Format: "json", Select: "$", + Map: map[string]string{string(syncdomain.FieldPrompt): "prompt"}, + }, + syncdomain.VariationProjection{Messages: &messages}, ) require.NoError(t, err) - state, ok := syncdomain.ProjectionFieldState(projection, syncdomain.FieldPrompt) - require.True(t, ok) - require.Equal(t, syncdomain.CapabilityReadOnly, state.Capability) + assert.Equal(t, CapabilityReadOnly, result.Fields[syncdomain.FieldPrompt].Capability) + + assert.False(t, fieldAllowed(syncdomain.Kind("unknown"), syncdomain.FieldPrompt)) + _, err = parseSourceDocument(Document{}, "missing") + require.Error(t, err) + number, ok := sourceNumber(1) + assert.True(t, ok) + assert.Equal(t, 1.0, number) + + content := stringNode("old", 0, 3, func(string) ([]byte, error) { return nil, assert.AnError }) + update := updateMessageContents( + sourceBundle("value.json", []byte("old")), + &sourceNode{items: []*sourceNode{{fields: map[string]*sourceNode{"content": content}}}}, + []syncdomain.Message{{Role: syncdomain.RoleSystem, Content: "old"}}, + []syncdomain.Message{{Role: syncdomain.RoleSystem, Content: "new"}}, + ) + assert.Equal(t, CapabilityReadOnly, update.Capability) +} + +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(SourceBundle{Primary: 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 findNodeByText(root *sitter.Node, source []byte, kind, text string) *sitter.Node { + var found *sitter.Node + walkTree(root, func(node *sitter.Node) { + if found == nil && node.Kind() == kind && nodeText(node, source) == text { + found = node + } + }) + return found } + +func mustTOMLString(t *testing.T, value, previous string) string { + t.Helper() + encoded, err := encodeTOMLString(value, previous) + require.NoError(t, err) + return string(encoded) +} + +type contractAdapter struct{} + +func (contractAdapter) Format() string { return "" } +func (contractAdapter) Inspect(SourceBundle) (Inspection, error) { + return NewInspection(nil), nil +} +func (contractAdapter) Defaults(syncdomain.Kind) (string, map[string]string) { return "$", nil } + +func stringPointerForReference(value string) *string { return &value } diff --git a/internal/sync/reference/framework_conformance_test.go b/internal/sync/reference/framework_conformance_test.go index d9877d17..7ea180d0 100644 --- a/internal/sync/reference/framework_conformance_test.go +++ b/internal/sync/reference/framework_conformance_test.go @@ -53,9 +53,6 @@ func TestFrameworkInspectorsReadAndWriteStaticFields(t *testing.T) { {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 { - if test.format != "python" { - continue - } t.Run(test.name, func(t *testing.T) { source := fixtureFile(t, test.fixture) mode := test.mode @@ -101,9 +98,6 @@ func TestFrameworkMessageListsAndBareCallIDs(t *testing.T) { {name: "Vercel streamText messages", fixture: "typescript/frameworks.ts", format: "typescript", id: "streamed"}, } for _, test := range messageTests { - if test.format != "python" { - continue - } t.Run(test.name, func(t *testing.T) { source := fixtureFile(t, test.fixture) id := syncdomain.VariationID("project", "config", "default") @@ -139,9 +133,6 @@ func TestFrameworkMessageListsAndBareCallIDs(t *testing.T) { {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 { - if test.format != "python" { - continue - } t.Run(test.name, func(t *testing.T) { source := fixtureFile(t, test.fixture) id := syncdomain.VariationID("project", "config", "default") @@ -168,9 +159,6 @@ func TestFrameworkSchemasAndFunctionBuiltPromptsStayReadOnly(t *testing.T) { {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 { - if test.format != "python" { - continue - } t.Run(test.name, func(t *testing.T) { source := fixtureFile(t, test.fixture) id := syncdomain.VariationID("project", "config", "default") diff --git a/internal/sync/reference/import_test.go b/internal/sync/reference/import_test.go index 89c017c5..f5991306 100644 --- a/internal/sync/reference/import_test.go +++ b/internal/sync/reference/import_test.go @@ -14,6 +14,7 @@ func TestImportedLiteralReadAndWrite(t *testing.T) { 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) { diff --git a/internal/sync/reference/placeholder_test.go b/internal/sync/reference/placeholder_test.go index ac7308d5..77275a79 100644 --- a/internal/sync/reference/placeholder_test.go +++ b/internal/sync/reference/placeholder_test.go @@ -35,6 +35,31 @@ prompt = PromptTemplate.from_template("Hello {customer}.") assert.Contains(t, string(updated), `"Welcome {customer_name}."`) } +func TestDollarPlaceholderTranslationRoundTrip(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 := ParseSource( + sourceBundle(reference.File, source), id, syncdomain.VariationModeAgent, reference, + ) + require.NoError(t, err) + assert.Equal(t, "Hello {{customer}}.", *projection.(syncdomain.VariationProjection).Instructions) + + next := "Welcome {{customer_name}}." + result, err := UpdateSource( + sourceBundle(reference.File, source), id, syncdomain.VariationModeAgent, reference, + syncdomain.VariationProjection{Mode: syncdomain.VariationModeAgent, Instructions: &next}, + ) + require.NoError(t, err) + updated := applyConformanceEdits(t, source, directEdits(t, result)) + assert.Contains(t, string(updated), `"Welcome ${customer_name}."`) +} + func TestAmbiguousPlaceholdersAreReadOnly(t *testing.T) { tests := []struct { name, template string diff --git a/internal/sync/reference/source_test.go b/internal/sync/reference/source_test.go index d86431ea..a2a9e0c5 100644 --- a/internal/sync/reference/source_test.go +++ b/internal/sync/reference/source_test.go @@ -1,6 +1,7 @@ package reference import ( + "bytes" "testing" "unicode/utf8" @@ -10,12 +11,40 @@ import ( syncdomain "github.com/launchdarkly/ldcli/internal/sync" ) -func TestPathParserReadsTwoFieldFilter(t *testing.T) { - path, err := parsePath(`items[id="one",role="system"]`) - require.NoError(t, err) - require.Len(t, path.steps, 1) - assert.Equal(t, "items", path.steps[0].key) - assert.Equal(t, []pathFilter{{key: "id", value: "one"}, {key: "role", value: "system"}}, path.steps[0].filters) +func TestParsePathGrammar(t *testing.T) { + for _, path := range []string{ + "$", + "field.child", + `items[id="value"]`, + `items[role="system",name="intro"]`, + "items[0]", + "items[0][1]", + "items[2147483647]", + `items[id="value"].messages[0].content`, + `object["literal.key"]`, + `["top level"]`, + } { + require.NoError(t, ParsePath(path), path) + } + for _, path := range []string{ + "", + "$.field", + "items[]", + "items[-1]", + "items[1.0]", + "items[01]", + "items[2147483648]", + `items[0][id="value"]`, + "items[*]", + `items[id=value]`, + `items[ id = "value" ]`, + "items..id", + } { + err := ParsePath(path) + problem, ok := syncdomain.ProblemFrom(err) + require.True(t, ok, path) + assert.Equal(t, syncdomain.ErrorInvalidPathSyntax, problem.Code, path) + } } func TestResolvePathSupportsChainedAndMixedIndexes(t *testing.T) { @@ -57,66 +86,479 @@ func TestResolvePathIndexErrorsUseStableCodes(t *testing.T) { } } -func TestIndexedSelectionIdentityUsesStableFieldsAndFallback(t *testing.T) { - identified := &sourceNode{fields: map[string]*sourceNode{ - "id": {value: "support"}, - "n": {value: 1}, +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 := ParsePath(path) + if err == nil { + return + } + problem, ok := syncdomain.ProblemFrom(err) + require.True(t, ok) + require.Equal(t, syncdomain.ErrorInvalidPathSyntax, problem.Code) + }) +} + +func TestResolvePathSupportsLiteralKeysAndRejectsAmbiguousSelectors(t *testing.T) { + root := &sourceNode{fields: map[string]*sourceNode{ + "metadata": {fields: map[string]*sourceNode{"display.name": {value: "Support"}}}, + "agents": { + items: []*sourceNode{ + {fields: map[string]*sourceNode{"id": {value: "support"}}}, + {fields: map[string]*sourceNode{"id": {value: "support"}}}, + }, + }, }} - assert.Equal(t, map[string]any{"id": "support"}, directSelectionIdentity(identified)) - assert.Nil(t, directSelectionIdentity(nil)) - assert.True(t, scalarValue(uint64(2))) - assert.False(t, scalarValue(nil)) - assert.False(t, sourceCollection(nil)) - assert.True(t, sourceCollection(&sourceNode{value: []string{"one"}})) + value, err := resolveSourcePath(root, `metadata["display.name"]`) + require.NoError(t, err) + assert.Equal(t, "Support", value.value) - cycle := &sourceNode{fields: map[string]*sourceNode{}} - cycle.fields["self"] = cycle - fallback := &sourceNode{fields: map[string]*sourceNode{"prompt": {value: "Help"}}} - selections, err := indexedSelections( - []indexedResolution{ - {path: "items[0]", collectionLength: 1, item: identified}, - {path: "items[0].children[0]", collectionLength: 1, item: fallback}, - {path: "items[0].cycle[0]", collectionLength: 1, item: cycle}, + _, err = resolveSourcePath(root, `agents[id="support"]`) + problem, ok := syncdomain.ProblemFrom(err) + require.True(t, ok) + assert.Equal(t, syncdomain.ErrorPathAmbiguous, problem.Code) +} + +func TestResolvePathMatchesEveryFilterCondition(t *testing.T) { + root := &sourceNode{fields: map[string]*sourceNode{ + "items": {items: []*sourceNode{ + {fields: map[string]*sourceNode{ + "role": {value: "system"}, "name": {value: "intro"}, "content": {value: "First"}, + }}, + {fields: map[string]*sourceNode{ + "role": {value: "system"}, "name": {value: "outro"}, "content": {value: "Second"}, + }}, + }}, + }} + + value, err := resolveSourcePath(root, `items[role="system",name="intro"].content`) + + require.NoError(t, err) + assert.Equal(t, "First", value.value) +} + +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 := ParseSource(sourceBundle("AGENTS.md", source), id, syncdomain.VariationModeAgent, reference) + require.NoError(t, err) + assert.Equal(t, "Help with orders.", *projection.(syncdomain.VariationProjection).Instructions) + + next := "Help with orders and refunds." + update, err := UpdateSource( + sourceBundle("AGENTS.md", source), + id, + syncdomain.VariationModeAgent, + reference, + syncdomain.VariationProjection{Mode: syncdomain.VariationModeAgent, Instructions: &next}, + ) + require.NoError(t, err) + edit := update.Fields[syncdomain.FieldPrompt].Edits[0] + updated := applyTestEdits(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 := ParseSource(sourceBundle(reference.File, source), id, syncdomain.VariationModeAgent, 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 := ParseSource(sourceBundle(reference.File, source), id, syncdomain.VariationModeCompletion, reference) + require.NoError(t, err) + messages := *projection.(syncdomain.VariationProjection).Messages + require.Equal(t, []syncdomain.Message{{Role: syncdomain.RoleSystem, Content: "Current system prompt."}}, messages) + + desired := []syncdomain.Message{{Role: syncdomain.RoleSystem, Content: "Updated system prompt."}} + update, err := UpdateSource( + sourceBundle(reference.File, source), + id, + syncdomain.VariationModeCompletion, + reference, + syncdomain.VariationProjection{Mode: syncdomain.VariationModeCompletion, Messages: &desired}, + ) + require.NoError(t, err) + assert.Equal(t, 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 := ParseSource(sourceBundle(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 := UpdateSource( + sourceBundle(reference.File, source), + id, + "", + reference, + syncdomain.SkillProjection{Description: &description, Markdown: &markdown}, + ) + require.NoError(t, err) + assert.Equal(t, CapabilityDirect, update.Fields[syncdomain.FieldDescription].Capability) + assert.Equal(t, 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 := UpdateSource( + sourceBundle(reference.File, source), + id, + "", + reference, + syncdomain.SkillProjection{Description: &description}, + ) + + require.NoError(t, err) + updated := applyTestEdits(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 := ParseSource( + sourceBundle(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) + } + }) + } +} + +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 := ParseSource(sourceBundle(reference.File, source), id, syncdomain.VariationModeAgent, 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 := UpdateSource( + sourceBundle(reference.File, source), + id, + syncdomain.VariationModeAgent, + 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}, + }, }, - fallback, ) require.NoError(t, err) - require.Len(t, selections, 3) - for _, selection := range selections { - assert.NotEmpty(t, selection.Fingerprint) + assert.Equal(t, CapabilityDirect, update.Fields[syncdomain.FieldPrompt].Capability) + assert.Equal(t, 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 := ParseSource(sourceBundle(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 := ParseSource(sourceBundle(reference.File, source), id, syncdomain.VariationModeAgent, reference) + + require.NoError(t, err) + assert.Equal(t, "Summarize the case.\nKeep the answer short.", + *projection.(syncdomain.VariationProjection).Instructions) } -func TestMarkdownSectionUpdatePreservesOtherSections(t *testing.T) { - source := []byte("# Support\nOld prompt\n\n# Other\nKeep this.\n") - id := syncdomain.VariationID("project", "config", "default") +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: "AGENTS.md", Format: "markdown", - Select: `sections[heading="Support"]`, Map: map[string]string{"prompt": "body"}, + File: "tools.ts", Format: "typescript", Select: `tools[id="lookup"]`, + Map: map[string]string{"description": "description", "schema": "inputSchema"}, } - prompt := "New prompt" - result, err := UpdateSource( - sourceBundle("AGENTS.md", source), + projection, err := ParseSource(sourceBundle(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 := UpdateSource( + sourceBundle(reference.File, source), + id, + "", + reference, + syncdomain.ToolProjection{Description: &description, Schema: &schema}, + ) + require.NoError(t, err) + assert.Equal(t, CapabilityDirect, update.Fields[syncdomain.FieldDescription].Capability) + assert.Equal(t, CapabilityDirect, update.Fields[syncdomain.FieldSchema].Capability) +} + +type fakeAdapter struct{} + +func (fakeAdapter) Format() string { return "fake-test" } +func (fakeAdapter) Inspect(bundle SourceBundle) (Inspection, error) { + document := bundle.Primary + return Inspection{root: stringNode(string(document.Data), 0, len(document.Data), func(text string) ([]byte, error) { + return []byte(text), nil + })}, nil +} +func (fakeAdapter) Defaults(kind syncdomain.Kind) (string, map[string]string) { + return "$", map[string]string{string(syncdomain.FieldPrompt): "$"} +} + +func TestRegisteredAdapterRunsThroughSharedCore(t *testing.T) { + require.NoError(t, Register(fakeAdapter{})) + id := syncdomain.VariationID("project", "config", "default") + + projection, err := ParseSource( + sourceBundle("prompt.fake", []byte("Registered without a core change.")), id, syncdomain.VariationModeAgent, + syncdomain.Reference{File: "prompt.fake", Format: "fake-test"}, + ) + + require.NoError(t, err) + assert.Equal(t, "Registered without a core change.", *projection.(syncdomain.VariationProjection).Instructions) +} + +func TestRegistryRejectsInvalidAndDuplicateAdapters(t *testing.T) { + require.Error(t, Register(nil)) + adapter := namedFakeAdapter{format: "duplicate-test"} + require.NoError(t, Register(adapter)) + require.Error(t, Register(adapter)) + + _, err := registeredAdapter("not-registered") + problem, ok := syncdomain.ProblemFrom(err) + require.True(t, ok) + assert.Equal(t, syncdomain.ErrorUnsupportedFormat, 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 := 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) + } +} + +type namedFakeAdapter struct{ format string } + +func (adapter namedFakeAdapter) Format() string { return adapter.format } +func (namedFakeAdapter) Inspect(bundle SourceBundle) (Inspection, error) { + return fakeAdapter{}.Inspect(bundle) +} +func (namedFakeAdapter) Defaults(kind syncdomain.Kind) (string, map[string]string) { + return fakeAdapter{}.Defaults(kind) +} + +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 := ParseSource(sourceBundle(reference.File, source), id, syncdomain.VariationModeAgent, 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 := ParseSource(sourceBundle(reference.File, source), id, syncdomain.VariationModeAgent, reference) + + require.NoError(t, err) + assert.Equal(t, "Summarize the case.", *projection.(syncdomain.VariationProjection).Instructions) +} + +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 := UpdateSource( + sourceBundle(reference.File, source), + id, + "", reference, - syncdomain.VariationProjection{Instructions: &prompt}, + syncdomain.ToolProjection{Description: &description}, ) require.NoError(t, err) - updated := applyConformanceEdits(t, source, directEdits(t, result)) - assert.Contains(t, string(updated), "# Other\nKeep this.") + assert.Equal(t, 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 := ParseSource(sourceBundle(reference.File, []byte{0xff}), id, syncdomain.VariationModeAgent, reference) + + require.ErrorContains(t, err, "valid UTF-8") +} + +func sourceBundle(path string, data []byte) SourceBundle { + return SourceBundle{Primary: Document{Path: path, Data: data}} +} + +func applyTestEdits(source []byte, edits ...TextEdit) []byte { + result := bytes.Clone(source) + for index := len(edits) - 1; index >= 0; index-- { + edit := edits[index] + result = append(append(append([]byte{}, result[:edit.Start]...), edit.Replacement...), result[edit.End:]...) + } + return result } func FuzzPathParserNeverPanics(f *testing.F) { - for _, seed := range []string{ - "$", "field.child", `items[id="one"]`, `object["literal.key"]`, - "items[0]", "items[-1]", "items[1.5]", "items[]", "items[2147483648]", - "items[0][1]", `items[id="support"].messages[0]`, + for _, value := range []string{ + "$", `agents[id="support"]`, `items[role="system",name="intro"]`, + `metadata["display.name"]`, "items[", } { - f.Add(seed) + f.Add(value) } - f.Fuzz(func(t *testing.T, path string) { - _ = ParsePath(path) + f.Fuzz(func(t *testing.T, value string) { + _ = ParsePath(value) }) } @@ -136,3 +578,62 @@ func FuzzPythonStringRoundTrip(f *testing.F) { } }) } + +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{'\'', '"'} { + encoded, err := encodeTypeScriptString(value, quote) + require.NoError(t, err) + decoded, _, err := decodeTypeScriptString(encoded) + require.NoError(t, err) + require.Equal(t, value, decoded) + } + }) +} + +func TestIndexedSelectionIdentityUsesStableFieldsAndFallback(t *testing.T) { + identified := &sourceNode{fields: map[string]*sourceNode{ + "id": {value: "support"}, + "n": {value: 1}, + }} + assert.Equal(t, map[string]any{"id": "support"}, directSelectionIdentity(identified)) + assert.Nil(t, directSelectionIdentity(nil)) + assert.True(t, scalarValue(uint64(2))) + assert.False(t, scalarValue(nil)) + assert.False(t, sourceCollection(nil)) + assert.True(t, sourceCollection(&sourceNode{value: []string{"one"}})) + assert.Equal(t, + []any{"one"}, + stableNodeValue(&sourceNode{items: []*sourceNode{{value: "one"}}, collection: true}, map[*sourceNode]bool{}), + ) + assert.Nil(t, stableNodeValue(&sourceNode{}, map[*sourceNode]bool{})) + + cycle := &sourceNode{fields: map[string]*sourceNode{}} + cycle.fields["self"] = cycle + fallback := &sourceNode{fields: map[string]*sourceNode{"prompt": {value: "Help"}}} + selections, err := indexedSelections( + []indexedResolution{ + {path: "items[0]", collectionLength: 1, item: identified}, + {path: "items[0].children[0]", collectionLength: 1, item: fallback}, + {path: "items[0].cycle[0]", collectionLength: 1, item: cycle}, + }, + fallback, + ) + require.NoError(t, err) + require.Len(t, selections, 3) + for _, selection := range selections { + assert.NotEmpty(t, selection.Fingerprint) + } + + _, err = indexedSelections( + []indexedResolution{{path: "items[0]", collectionLength: 1, item: &sourceNode{value: make(chan int)}}}, + fallback, + ) + require.ErrorContains(t, err, "encode indexed selection fingerprint") +} diff --git a/internal/sync/reference/testdata/imports/typescript/src/agent.ts b/internal/sync/reference/testdata/imports/typescript/src/agent.ts new file mode 100644 index 00000000..aa4cae5b --- /dev/null +++ b/internal/sync/reference/testdata/imports/typescript/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/reference/testdata/imports/typescript/src/prompts.ts b/internal/sync/reference/testdata/imports/typescript/src/prompts.ts new file mode 100644 index 00000000..2bb8bd61 --- /dev/null +++ b/internal/sync/reference/testdata/imports/typescript/src/prompts.ts @@ -0,0 +1 @@ +export const SUPPORT_PROMPT = "Ask for an order ID before discussing an order."; diff --git a/internal/sync/reference/testdata/javascript/server.js b/internal/sync/reference/testdata/javascript/server.js new file mode 100644 index 00000000..38a4de46 --- /dev/null +++ b/internal/sync/reference/testdata/javascript/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/reference/testdata/typescript/frameworks.ts b/internal/sync/reference/testdata/typescript/frameworks.ts new file mode 100644 index 00000000..e04ac5b0 --- /dev/null +++ b/internal/sync/reference/testdata/typescript/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/reference/testdata/typescript/langchain.ts b/internal/sync/reference/testdata/typescript/langchain.ts new file mode 100644 index 00000000..71481fe9 --- /dev/null +++ b/internal/sync/reference/testdata/typescript/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/reference/testdata/typescript/server.ts b/internal/sync/reference/testdata/typescript/server.ts new file mode 100644 index 00000000..f1d5d177 --- /dev/null +++ b/internal/sync/reference/testdata/typescript/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/reference/typescript.go b/internal/sync/reference/typescript.go new file mode 100644 index 00000000..05066608 --- /dev/null +++ b/internal/sync/reference/typescript.go @@ -0,0 +1,724 @@ +package reference + +import ( + "encoding/json" + "path" + "regexp" + "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" +) + +type ecmaScriptAdapter struct { + format string + typed bool +} + +func (adapter ecmaScriptAdapter) Format() string { return adapter.format } +func (adapter ecmaScriptAdapter) Inspect(bundle SourceBundle) (Inspection, error) { + root, err := parseTypeScriptBundle(bundle, adapter.typed) + return Inspection{root: root}, err +} +func (ecmaScriptAdapter) Defaults(syncdomain.Kind) (string, map[string]string) { return "", nil } +func (adapter ecmaScriptAdapter) Dependencies(document Document) ([]string, error) { + language := sitter.NewLanguage(typescript.LanguageTypescript()) + if !adapter.typed { + language = sitter.NewLanguage(javascript.Language()) + } + tree, err := parseTree(document, language) + 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() { + mustRegister(ecmaScriptAdapter{format: "typescript", typed: true}) + mustRegister(ecmaScriptAdapter{format: "javascript"}) +} + +func parseTypeScriptBundle(bundle SourceBundle, typed bool) (*sourceNode, error) { + return parseTypeScript(bundle.Primary, typed, typeScriptImportedValues(bundle, typed)) +} + +func parseTypeScript(document Document, typed bool, imported map[string]*sourceNode) (*sourceNode, error) { + language := sitter.NewLanguage(typescript.LanguageTypescript()) + if !typed { + language = sitter.NewLanguage(javascript.Language()) + } + tree, err := parseTree(document, language) + if err != nil { + return nil, err + } + defer tree.Close() + + imports := typeScriptImports(tree.RootNode(), document.Data) + servers := typeScriptServers(tree.RootNode(), document.Data, imports) + instances := typeScriptInstances(tree.RootNode(), document.Data, imports) + constants, constantResources := typeScriptConstants(tree.RootNode(), document.Data) + for name, value := range imported { + constants[name] = value + } + collections := map[string][]*sourceNode{"agents": {}, "prompts": {}, "tools": {}, "constants": {}} + collections["constants"] = constantResources + walkTree(tree.RootNode(), func(node *sitter.Node) { + if node.Kind() != "call_expression" { + return + } + function := node.ChildByFieldName("function") + if function == nil || function.Kind() != "member_expression" { + return + } + receiver := function.ChildByFieldName("object") + method := function.ChildByFieldName("property") + if receiver == nil || method == nil || !servers[nodeText(receiver, document.Data)] || + nodeText(method, document.Data) != "registerTool" { + return + } + arguments := namedChildren(node.ChildByFieldName("arguments")) + if len(arguments) < 2 { + return + } + idNode := typeScriptValue(arguments[0], document.Data, constants) + id, ok := idNode.value.(string) + if !ok { + return + } + fields := typeScriptObject(arguments[1], document.Data, constants) + collections["tools"] = append(collections["tools"], logicalResource(id, fields.fields)) + }) + walkTree(tree.RootNode(), func(node *sitter.Node) { + if node.Kind() != "variable_declarator" { + return + } + name := node.ChildByFieldName("name") + value := node.ChildByFieldName("value") + if name == nil || value == nil { + return + } + id := typeScriptQualifiedID(node, nodeText(name, document.Data), document.Data) + for _, call := range typeScriptPromptCalls(value, imports, document.Data) { + fields := typeScriptPromptFields(call, imports, document.Data, constants) + collections["prompts"] = append(collections["prompts"], logicalResource(id, fields)) + } + if collection, resource, ok := typeScriptFrameworkCall( + value, id, imports, instances, document.Data, constants, + ); ok { + collections[collection] = append(collections[collection], resource) + } + switch value.Kind() { + case "object": + if id != "tools" || imports["tool"] != "vercel-tool" { + return + } + for key, child := range typeScriptObject(value, document.Data, constants).fields { + call := child.fields["call"] + if call != nil && call.fields != nil { + collections["tools"] = append(collections["tools"], logicalResource(key, call.fields)) + } + } + } + }) + walkTree(tree.RootNode(), func(node *sitter.Node) { + if node.Kind() != "call_expression" || typeScriptAssignedCall(node) { + return + } + if collection, resource, ok := typeScriptFrameworkCall( + node, "", imports, instances, document.Data, constants, + ); ok { + collections[collection] = append(collections[collection], resource) + } + }) + return logicalRoot(collections), nil +} + +func typeScriptAssignedCall(call *sitter.Node) bool { + for parent := call.Parent(); parent != nil; parent = parent.Parent() { + if parent.Kind() == "variable_declarator" { + return true + } + if parent.Kind() == "expression_statement" || parent.Kind() == "return_statement" { + return false + } + } + return false +} + +func typeScriptConstants(root *sitter.Node, source []byte) (map[string]*sourceNode, []*sourceNode) { + counts := map[string]int{} + values := map[string]*sourceNode{} + var resources []*sourceNode + walkTree(root, func(node *sitter.Node) { + if node.Kind() != "variable_declarator" { + return + } + name := node.ChildByFieldName("name") + valueNode := node.ChildByFieldName("value") + if name == nil || valueNode == nil || name.Kind() != "identifier" { + return + } + id := typeScriptQualifiedID(node, nodeText(name, source), source) + value := typeScriptValue(valueNode, source, nil) + if value.value == nil && value.fields == nil && value.items == nil { + return + } + counts[id]++ + values[id] = value + resources = append(resources, constantResource(id, value)) + }) + mutated := map[string]bool{} + walkTree(root, func(node *sitter.Node) { + switch node.Kind() { + case "assignment_expression", "augmented_assignment_expression": + left := node.ChildByFieldName("left") + if rootName := typeScriptMutationRoot(left, source); rootName != "" { + mutated[typeScriptQualifiedID(node, rootName, source)] = true + } + case "call_expression": + function := node.ChildByFieldName("function") + if function == nil { + return + } + text := nodeText(function, source) + rootName, method, ok := strings.Cut(text, ".") + if ok && (method == "push" || method == "splice" || method == "pop" || + method == "shift" || method == "unshift" || method == "sort" || method == "reverse") { + mutated[typeScriptQualifiedID(node, rootName, source)] = true + } + } + }) + for id, count := range counts { + if count != 1 || mutated[id] { + values[id] = nil + } + } + for _, resource := range resources { + id := resource.fields["id"].value.(string) + if mutated[id] { + markReadOnly(resource, "mutated constants are read-only") + } + } + return values, resources +} + +func typeScriptMutationRoot(node *sitter.Node, source []byte) string { + if node == nil { + return "" + } + text := nodeText(node, source) + if index := strings.IndexAny(text, ".["); index >= 0 { + text = text[:index] + } + return strings.TrimSpace(text) +} + +func typeScriptQualifiedID(node *sitter.Node, name string, source []byte) string { + for parent := node.Parent(); parent != nil; parent = parent.Parent() { + switch parent.Kind() { + case "function_declaration", "method_definition", "class_declaration": + scope := parent.ChildByFieldName("name") + if scope != nil { + return nodeText(scope, source) + "." + name + } + } + } + return name +} + +func typeScriptPromptCalls(node *sitter.Node, imports map[string]string, source []byte) []*sitter.Node { + var calls []*sitter.Node + walkTree(node, func(candidate *sitter.Node) { + if candidate.Kind() != "call_expression" && candidate.Kind() != "new_expression" { + return + } + function := candidate.ChildByFieldName("function") + if function == nil { + function = candidate.ChildByFieldName("constructor") + } + callable := nodeText(function, source) + owner, method, qualified := strings.Cut(callable, ".") + if imports[callable] == "prompt" || + (qualified && imports[owner] == "prompt" && (method == "fromMessages" || method == "fromTemplate")) { + calls = append(calls, candidate) + } + }) + return calls +} + +func typeScriptPromptFields( + call *sitter.Node, + imports map[string]string, + source []byte, + constants map[string]*sourceNode, +) map[string]*sourceNode { + function := call.ChildByFieldName("function") + if function == nil { + function = call.ChildByFieldName("constructor") + } + callable := nodeText(function, source) + owner, method, qualified := strings.Cut(callable, ".") + arguments := namedChildren(call.ChildByFieldName("arguments")) + if qualified && imports[owner] == "prompt" && len(arguments) != 0 { + if method == "fromMessages" { + return translateFieldPlaceholders( + map[string]*sourceNode{"messages": typeScriptMessages(arguments[0], source, constants)}, + bracePlaceholder, "messages", + ) + } + if method == "fromTemplate" { + return translateFieldPlaceholders( + map[string]*sourceNode{"template": typeScriptValue(arguments[0], source, constants)}, + bracePlaceholder, "template", + ) + } + } + if len(arguments) == 0 { + return map[string]*sourceNode{} + } + if arguments[0].Kind() == "array" { + return translateFieldPlaceholders( + map[string]*sourceNode{"messages": typeScriptMessages(arguments[0], source, constants)}, + bracePlaceholder, "messages", + ) + } + return translateFieldPlaceholders( + typeScriptObject(arguments[0], source, constants).fields, + bracePlaceholder, "template", "messages", + ) +} + +func typeScriptImports(root *sitter.Node, source []byte) map[string]string { + imports := map[string]string{} + namedPattern := regexp.MustCompile(`(?s)import\s*\{([^}]*)\}\s*from\s*["']([^"']+)["']`) + defaultPattern := regexp.MustCompile(`(?s)import\s+([A-Za-z_$][A-Za-z0-9_$]*)\s+from\s*["']([^"']+)["']`) + walkTree(root, func(node *sitter.Node) { + if node.Kind() != "import_statement" { + return + } + text := nodeText(node, source) + if match := defaultPattern.FindStringSubmatch(text); len(match) == 3 { + if allowed := typeScriptAllowedImports(match[2]); allowed != nil { + if kind := allowed["default"]; kind != "" { + imports[match[1]] = kind + } + } + } + match := namedPattern.FindStringSubmatch(text) + if len(match) != 3 { + return + } + module := match[2] + allowed := typeScriptAllowedImports(module) + if allowed == nil { + return + } + for _, item := range strings.Split(match[1], ",") { + parts := strings.Fields(strings.TrimSpace(item)) + if len(parts) == 0 { + continue + } + alias := parts[0] + if len(parts) == 3 && parts[1] == "as" { + alias = parts[2] + } + if kind := allowed[parts[0]]; kind != "" { + imports[alias] = kind + } + } + }) + return imports +} + +type typeScriptImportBinding struct { + module string + name string + alias string +} + +func typeScriptImportBindings(root *sitter.Node, source []byte) []typeScriptImportBinding { + namedPattern := regexp.MustCompile(`(?s)import\s*\{([^}]*)\}\s*from\s*["']([^"']+)["']`) + defaultPattern := regexp.MustCompile(`(?s)import\s+([A-Za-z_$][A-Za-z0-9_$]*)\s+from\s*["']([^"']+)["']`) + namespacePattern := regexp.MustCompile(`(?s)import\s*\*\s*as\s+([A-Za-z_$][A-Za-z0-9_$]*)\s+from\s*["']([^"']+)["']`) + var bindings []typeScriptImportBinding + walkTree(root, func(node *sitter.Node) { + if node.Kind() != "import_statement" { + return + } + text := nodeText(node, source) + if match := namespacePattern.FindStringSubmatch(text); len(match) == 3 && + typeScriptAllowedImports(match[2]) == nil { + bindings = append(bindings, typeScriptImportBinding{ + module: match[2], name: "*", alias: match[1], + }) + } + if match := defaultPattern.FindStringSubmatch(text); len(match) == 3 && + typeScriptAllowedImports(match[2]) == nil { + bindings = append(bindings, typeScriptImportBinding{ + module: match[2], name: "default", alias: match[1], + }) + } + match := namedPattern.FindStringSubmatch(text) + if len(match) != 3 || typeScriptAllowedImports(match[2]) != nil { + return + } + for _, item := range strings.Split(match[1], ",") { + parts := strings.Fields(strings.TrimSpace(item)) + if len(parts) == 0 { + continue + } + alias := parts[0] + if len(parts) == 3 && parts[1] == "as" { + alias = parts[2] + } + bindings = append(bindings, typeScriptImportBinding{ + module: match[2], name: parts[0], alias: alias, + }) + } + }) + return bindings +} + +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 +} + +func typeScriptModulePaths(primary, module string) []string { + base := path.Clean(path.Join(path.Dir(primary), module)) + for _, extension := range []string{".js", ".jsx", ".ts", ".tsx"} { + base = strings.TrimSuffix(base, extension) + } + return []string{ + base + ".ts", base + ".tsx", base + ".js", base + ".jsx", + path.Join(base, "index.ts"), path.Join(base, "index.js"), + } +} + +func typeScriptImportedValues(bundle SourceBundle, typed bool) map[string]*sourceNode { + result := map[string]*sourceNode{} + language := sitter.NewLanguage(typescript.LanguageTypescript()) + if !typed { + language = sitter.NewLanguage(javascript.Language()) + } + tree, err := parseTree(bundle.Primary, language) + if err != nil { + return result + } + defer tree.Close() + cache := map[string]map[string]*sourceNode{} + for _, binding := range typeScriptImportBindings(tree.RootNode(), bundle.Primary.Data) { + if !strings.HasPrefix(binding.module, ".") { + result[binding.alias] = unresolvedImportNode(binding.module, binding.name, binding.module) + continue + } + candidates := typeScriptModulePaths(bundle.Primary.Path, binding.module) + var document Document + for _, candidate := range candidates { + if value, ok := bundle.Dependencies[candidate]; ok { + document = value + break + } + } + if document.Path == "" { + result[binding.alias] = unresolvedImportNode(binding.module, binding.name, candidates[0]) + continue + } + constants := cache[document.Path] + if constants == nil { + dependencyLanguage := sitter.NewLanguage(typescript.LanguageTypescript()) + if strings.HasSuffix(document.Path, ".js") || strings.HasSuffix(document.Path, ".jsx") { + dependencyLanguage = sitter.NewLanguage(javascript.Language()) + } + dependencyTree, parseErr := parseTree(document, dependencyLanguage) + if parseErr != nil { + result[binding.alias] = unresolvedImportNode(binding.module, binding.name, document.Path) + continue + } + constants, _ = typeScriptConstants(dependencyTree.RootNode(), document.Data) + dependencyTree.Close() + for _, value := range constants { + markSourceFile(value, document.Path) + } + cache[document.Path] = constants + } + var value *sourceNode + if binding.name == "*" { + value = &sourceNode{fields: map[string]*sourceNode{}} + for name, constant := range constants { + value.fields[name] = constant + } + } else { + value = constants[binding.name] + } + if value == nil { + value = unresolvedImportNode(binding.module, binding.name, document.Path) + } + result[binding.alias] = value + } + return result +} + +func typeScriptServers(root *sitter.Node, source []byte, imports map[string]string) map[string]bool { + servers := map[string]bool{} + walkTree(root, func(node *sitter.Node) { + if node.Kind() != "variable_declarator" { + return + } + name := node.ChildByFieldName("name") + value := node.ChildByFieldName("value") + if name == nil || value == nil || value.Kind() != "new_expression" { + return + } + constructor := value.ChildByFieldName("constructor") + if constructor != nil && imports[nodeText(constructor, source)] == "mcp-server" { + servers[nodeText(name, source)] = true + } + }) + return servers +} + +func typeScriptObject(node *sitter.Node, source []byte, constants map[string]*sourceNode) *sourceNode { + if node == nil || node.Kind() != "object" { + if node == nil { + return readOnlyNode(0, 0, "object is missing") + } + return readOnlyNode(int(node.StartByte()), int(node.EndByte()), "dynamic expression is not a literal object") + } + start, end := int(node.StartByte()), int(node.EndByte()) + fields := map[string]*sourceNode{} + object := map[string]any{} + complete := true + for _, pair := range namedChildren(node) { + if pair.Kind() != "pair" { + fields[nodeText(pair, source)] = readOnlyNode( + int(pair.StartByte()), int(pair.EndByte()), "object entry is dynamic", + ) + complete = false + continue + } + keyNode := pair.ChildByFieldName("key") + valueNode := pair.ChildByFieldName("value") + key, ok := typeScriptPropertyName(keyNode, source) + if !ok { + key = nodeText(keyNode, source) + } + if _, duplicate := fields[key]; duplicate { + return readOnlyNode(start, end, "object contains a duplicate property") + } + value := typeScriptValue(valueNode, source, constants) + if key == "messages" && valueNode.Kind() == "array" { + value = typeScriptMessages(valueNode, source, constants) + } + if valueNode.Kind() == "call_expression" { + function := valueNode.ChildByFieldName("function") + arguments := namedChildren(valueNode.ChildByFieldName("arguments")) + if function != nil && nodeText(function, source) == "tool" && len(arguments) == 1 { + call := typeScriptObject(arguments[0], source, constants) + value = &sourceNode{fields: map[string]*sourceNode{"call": call}} + } + } + fields[key] = value + if value.value != nil { + object[key] = value.value + } else { + complete = false + } + } + if !complete { + return &sourceNode{ + fields: fields, start: start, end: end, capability: CapabilityReadOnly, + reason: "object contains a dynamic value", + } + } + return &sourceNode{ + value: object, fields: fields, start: start, end: end, capability: CapabilityDirect, + encode: func(value any) ([]byte, error) { return json.Marshal(value) }, + } +} + +func typeScriptValue(node *sitter.Node, source []byte, constants map[string]*sourceNode) *sourceNode { + if node == nil { + return readOnlyNode(0, 0, "value is missing") + } + start, end := int(node.StartByte()), int(node.EndByte()) + switch node.Kind() { + case "string", "template_string": + value, quote, err := decodeTypeScriptString(source[start:end]) + if err != nil { + return readOnlyNode(start, end, err.Error()) + } + return stringNode(value, start, end, func(text string) ([]byte, error) { + return encodeTypeScriptString(text, quote) + }) + case "number": + value, err := strconv.ParseFloat(string(source[start:end]), 64) + if err != nil { + return readOnlyNode(start, end, "number is not representable") + } + return numberNode(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([]*sourceNode, 0, node.NamedChildCount()) + complete := true + for _, child := range namedChildren(node) { + value := typeScriptValue(child, source, constants) + items = append(items, value) + values = append(values, value.value) + if value.value == nil { + complete = false + } + } + result := &sourceNode{ + value: values, items: items, collection: true, start: start, end: end, capability: CapabilityPatch, + reason: "array changes require patch review", + encode: func(value any) ([]byte, error) { return json.Marshal(value) }, + } + if !complete { + result.value = nil + result.capability = CapabilityReadOnly + result.reason = "array contains dynamic values" + result.encode = nil + } + return result + case "true": + return typeScriptBooleanNode(true, start, end) + case "false": + return typeScriptBooleanNode(false, start, end) + case "null": + return &sourceNode{value: nil, start: start, end: end, capability: CapabilityPatch} + case "member_expression": + if constants != nil { + object := node.ChildByFieldName("object") + property := node.ChildByFieldName("property") + if object != nil && property != nil { + if namespace := constants[nodeText(object, source)]; namespace != nil { + if value := namespace.fields[nodeText(property, source)]; value != nil { + return value + } + } + } + } + case "identifier": + if constants != nil { + if value := constants[nodeText(node, source)]; value != nil && value.end < start { + return value + } + } + } + return readOnlyNode(start, end, "dynamic expression has no safe inverse") +} + +func typeScriptBooleanNode(value bool, start, end int) *sourceNode { + return &sourceNode{ + value: value, start: start, end: end, capability: CapabilityDirect, + encode: func(next any) ([]byte, error) { + value, ok := next.(bool) + if !ok { + return nil, mapTypeError("source", "a boolean") + } + return []byte(strconv.FormatBool(value)), nil + }, + } +} + +func typeScriptMessages(node *sitter.Node, source []byte, constants map[string]*sourceNode) *sourceNode { + if node != nil && node.Kind() == "identifier" && constants != nil { + if messages := messageListFromCollection(constants[nodeText(node, source)]); messages != nil { + return messages + } + } + if node == nil || node.Kind() != "array" { + if node == nil { + return readOnlyNode(0, 0, "messages are missing") + } + return readOnlyNode(int(node.StartByte()), int(node.EndByte()), "messages are not a literal array") + } + start, end := int(node.StartByte()), int(node.EndByte()) + var items []*sourceNode + var messages []syncdomain.Message + complete := true + for _, child := range 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 { + complete = false + continue + } + messages = append(messages, syncdomain.Message{ + Role: normalizeMessageRole(role), Content: content, + }) + } + result := &sourceNode{ + items: items, collection: true, start: start, end: end, capability: CapabilityDirect, + reason: "message structure is read-only", + } + if complete { + result.value = messages + } else { + result.capability = CapabilityReadOnly + result.reason = "dynamic message content is read-only" + } + return result +} + +func typeScriptMessage(node *sitter.Node, source []byte, constants map[string]*sourceNode) *sourceNode { + fields := map[string]*sourceNode{} + switch node.Kind() { + case "array": + children := namedChildren(node) + if len(children) == 2 { + fields["role"] = typeScriptValue(children[0], source, constants) + fields["content"] = typeScriptValue(children[1], source, constants) + } + case "object": + fields = typeScriptObject(node, source, constants).fields + } + if fields["role"] == nil { + fields["role"] = readOnlyNode(int(node.StartByte()), int(node.EndByte()), "message role is not static") + } + if fields["content"] == nil { + fields["content"] = readOnlyNode(int(node.StartByte()), int(node.EndByte()), "message content is not static") + } + return &sourceNode{fields: fields} +} + +func typeScriptPropertyName(node *sitter.Node, source []byte) (string, bool) { + if node == nil { + return "", false + } + switch node.Kind() { + case "property_identifier", "identifier": + return nodeText(node, source), true + case "string": + value := typeScriptValue(node, source, nil) + text, ok := value.value.(string) + return text, ok + case "computed_property_name": + return strings.TrimSpace(strings.Trim(nodeText(node, source), "[]")), true + case "number": + return nodeText(node, source), true + default: + return "", false + } +} diff --git a/internal/sync/reference/typescript_frameworks.go b/internal/sync/reference/typescript_frameworks.go new file mode 100644 index 00000000..0571b8c4 --- /dev/null +++ b/internal/sync/reference/typescript_frameworks.go @@ -0,0 +1,210 @@ +package reference + +import ( + "strings" + + sitter "github.com/tree-sitter/go-tree-sitter" +) + +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"}}, +} + +type typeScriptCallRule struct { + collection string + objectArg int +} + +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 +} + +func typeScriptInstances(root *sitter.Node, source []byte, imports map[string]string) map[string]string { + instances := map[string]string{} + walkTree(root, func(node *sitter.Node) { + if node.Kind() != "variable_declarator" { + return + } + name := node.ChildByFieldName("name") + value := node.ChildByFieldName("value") + if name == nil || value == nil || value.Kind() != "new_expression" && value.Kind() != "call_expression" { + return + } + function := value.ChildByFieldName("constructor") + if function == nil { + function = value.ChildByFieldName("function") + } + if kind := imports[nodeText(function, source)]; kind != "" { + instances[nodeText(name, source)] = kind + } + }) + return instances +} + +func typeScriptFrameworkCall( + call *sitter.Node, + id string, + imports, instances map[string]string, + source []byte, + constants map[string]*sourceNode, +) (collection string, resource *sourceNode, ok bool) { + if call == nil || call.Kind() != "call_expression" && call.Kind() != "new_expression" { + return "", nil, false + } + function := call.ChildByFieldName("function") + if function == nil { + function = call.ChildByFieldName("constructor") + } + callable := nodeText(function, source) + kind := imports[callable] + receiver, method, qualified := strings.Cut(callable, ".") + if qualified { + switch instances[receiver] { + 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": + if method == "tool" { + kind = "mcp-tool" + } + if method == "registerPrompt" { + kind = "mcp-prompt" + } + } + } + rule, exists := typeScriptCallRules[kind] + arguments := namedChildren(call.ChildByFieldName("arguments")) + if !exists { + if kind == "mcp-tool" { + return typeScriptMCPTool(arguments, id, source, constants) + } + if kind == "mcp-prompt" { + return typeScriptMCPPrompt(arguments, id, source, constants) + } + return "", nil, false + } + if rule.objectArg >= len(arguments) || arguments[rule.objectArg].Kind() != "object" { + return "", nil, false + } + fields := typeScriptObject(arguments[rule.objectArg], source, constants).fields + switch rule.collection { + case "agents": + fields = translateFieldPlaceholders( + fields, dollarPlaceholder, "instructions", "prompt", "system", + ) + case "prompts": + fields = translateFieldPlaceholders( + fields, dollarPlaceholder, "instructions", "prompt", "system", "messages", + ) + } + if id == "" { + id = staticSourceString(fields["name"]) + } + if id == "" { + id = staticSourceString(fields["id"]) + } + if id == "" { + id = typeScriptQualifiedCallID(call, callable, source) + } + return rule.collection, logicalResource(id, fields), true +} + +func typeScriptMCPTool( + arguments []*sitter.Node, + id string, + source []byte, + constants map[string]*sourceNode, +) (string, *sourceNode, bool) { + if len(arguments) < 3 { + return "", nil, false + } + name := typeScriptValue(arguments[0], source, constants) + if id == "" { + id, _ = name.value.(string) + } + fields := map[string]*sourceNode{ + "name": name, + "description": typeScriptValue(arguments[1], source, constants), + "schema": typeScriptValue(arguments[2], source, constants), + } + return "tools", logicalResource(id, fields), id != "" +} + +func typeScriptMCPPrompt( + arguments []*sitter.Node, + id string, + source []byte, + constants map[string]*sourceNode, +) (string, *sourceNode, bool) { + if len(arguments) == 0 { + return "", nil, false + } + name := typeScriptValue(arguments[0], source, constants) + if id == "" { + id, _ = name.value.(string) + } + fields := map[string]*sourceNode{"name": name} + if len(arguments) > 1 && arguments[1].Kind() == "object" { + for key, value := range typeScriptObject(arguments[1], source, constants).fields { + fields[key] = value + } + } + fields["template"] = readOnlyNode( + int(callStart(arguments[0])), int(callEnd(arguments[len(arguments)-1])), + "prompt built by a handler is read-only", + ) + return "prompts", logicalResource(id, fields), id != "" +} + +func typeScriptQualifiedCallID(call *sitter.Node, callable string, source []byte) string { + for parent := call.Parent(); parent != nil; parent = parent.Parent() { + if parent.Kind() == "function_declaration" || parent.Kind() == "method_definition" { + if name := parent.ChildByFieldName("name"); name != nil { + return nodeText(name, source) + "." + callable + } + } + } + return callable +} + +func callStart(node *sitter.Node) uint { return node.StartByte() } +func callEnd(node *sitter.Node) uint { return node.EndByte() }