diff --git a/internal/sync/reference/defensive_test.go b/internal/sync/reference/defensive_test.go index 064511af..8ae4bcaf 100644 --- a/internal/sync/reference/defensive_test.go +++ b/internal/sync/reference/defensive_test.go @@ -33,3 +33,11 @@ func TestStructuredAdaptersRejectMalformedSources(t *testing.T) { }) } } + +func TestMarkdownProfilesRejectUnsafeMarkers(t *testing.T) { + _, err := parseMarkdownProfile( + Document{Path: "instructions.md", Data: []byte("\n")}, + false, + ) + require.Error(t, err) +} diff --git a/internal/sync/reference/profile_test.go b/internal/sync/reference/profile_test.go new file mode 100644 index 00000000..734af835 --- /dev/null +++ b/internal/sync/reference/profile_test.go @@ -0,0 +1,81 @@ +package reference + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" +) + +func TestMarkdownProfilesPreserveActivationMetadata(t *testing.T) { + tests := []struct { + name, fixture, realPath, format, metadataPath string + metadataValue any + }{ + {name: "Cursor", fixture: "profiles/cursor-rule.mdc", realPath: ".cursor/rules/support.mdc", format: "cursor", metadataPath: "frontmatter.globs", metadataValue: "src/support/**/*.ts"}, + {name: "Copilot", fixture: "profiles/copilot.instructions.md", realPath: ".github/instructions/support.instructions.md", format: "copilot", metadataPath: "frontmatter.applyTo", metadataValue: "src/support/**/*.ts"}, + {name: "Claude", fixture: "profiles/claude-instructions.md.golden", realPath: "CLAUDE.md", format: "claude-markdown", metadataPath: "frontmatter.model", metadataValue: "sonnet"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + documentPath, source := materializeFixture(t, test.fixture, test.realPath) + id := syncdomain.VariationID("project", "config", "default") + reference := syncdomain.Reference{File: documentPath, Format: test.format} + root, err := parseSourceDocument(Document{Path: documentPath, Data: source}, test.format) + require.NoError(t, err) + metadata, err := resolveSourcePath(root, test.metadataPath) + require.NoError(t, err) + assert.Equal(t, test.metadataValue, metadata.value) + + projection, err := ParseSource(sourceBundle(documentPath, source), id, syncdomain.VariationModeAgent, reference) + require.NoError(t, err) + prompt := "Updated profile instructions." + result, err := UpdateSource( + sourceBundle(documentPath, source), id, syncdomain.VariationModeAgent, reference, + syncdomain.VariationProjection{Mode: syncdomain.VariationModeAgent, Instructions: &prompt}, + ) + require.NoError(t, err) + updated := applyConformanceEdits(t, source, directEdits(t, result)) + root, err = parseSourceDocument(Document{Path: documentPath, Data: updated}, test.format) + require.NoError(t, err) + metadata, err = resolveSourcePath(root, test.metadataPath) + require.NoError(t, err) + assert.Equal(t, test.metadataValue, metadata.value) + assert.NotNil(t, projection) + }) + } +} + +func TestAgentsMarkdownSectionsAndMarkers(t *testing.T) { + documentPath, source := materializeFixture(t, "profiles/agents-instructions.md.golden", "AGENTS.md") + id := syncdomain.VariationID("project", "config", "default") + tests := []struct { + name, selectPath, mapPath, want string + }{ + {name: "section", selectPath: `sections[heading="Support"]`, mapPath: "body", want: "Ask for an order ID.\n\n\nExplain the refund policy.\n"}, + {name: "marker", selectPath: `markers[id="refunds"]`, mapPath: "body", want: "Explain the refund policy."}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + reference := syncdomain.Reference{ + File: documentPath, Format: "agents-markdown", Select: test.selectPath, + Map: map[string]string{"prompt": test.mapPath}, + } + projection, err := ParseSource( + sourceBundle(reference.File, source), id, syncdomain.VariationModeAgent, reference, + ) + require.NoError(t, err) + assert.Equal(t, test.want, *projection.(syncdomain.VariationProjection).Instructions) + prompt := "Updated selected instructions." + result, err := UpdateSource( + sourceBundle(reference.File, source), id, syncdomain.VariationModeAgent, reference, + syncdomain.VariationProjection{Mode: syncdomain.VariationModeAgent, Instructions: &prompt}, + ) + require.NoError(t, err) + updated := applyConformanceEdits(t, source, directEdits(t, result)) + assert.Contains(t, string(updated), "## Build\n\nRun `make test`.") + }) + } +} diff --git a/internal/sync/reference/profiles.go b/internal/sync/reference/profiles.go new file mode 100644 index 00000000..68aeb103 --- /dev/null +++ b/internal/sync/reference/profiles.go @@ -0,0 +1,126 @@ +package reference + +import ( + "bytes" + "fmt" + "strings" + + "gopkg.in/yaml.v3" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" +) + +type markdownProfileAdapter struct { + format string + frontMatter bool +} + +func (adapter markdownProfileAdapter) Format() string { return adapter.format } +func (adapter markdownProfileAdapter) Inspect(bundle SourceBundle) (Inspection, error) { + root, err := parseMarkdownProfile(bundle.Primary, adapter.frontMatter) + return Inspection{root: root}, err +} +func (markdownProfileAdapter) Defaults(kind syncdomain.Kind) (string, map[string]string) { + if kind == syncdomain.KindVariation { + return "$", map[string]string{string(syncdomain.FieldPrompt): "body"} + } + return "$", nil +} + +func init() { + mustRegister(markdownProfileAdapter{format: "cursor", frontMatter: true}) + mustRegister(markdownProfileAdapter{format: "copilot", frontMatter: true}) + mustRegister(markdownProfileAdapter{format: "agents-markdown"}) + mustRegister(markdownProfileAdapter{format: "claude-markdown", frontMatter: true}) +} + +func parseMarkdownProfile(document Document, supportsFrontMatter bool) (*sourceNode, error) { + data := document.Data + bodyStart := 0 + frontmatter := &sourceNode{value: map[string]any{}, fields: map[string]*sourceNode{}} + trimmed := bytes.TrimPrefix(data, []byte("\ufeff")) + bomLength := len(data) - len(trimmed) + if supportsFrontMatter && bytes.HasPrefix(trimmed, []byte("---")) { + region, start, err := splitFrontMatter(data) + if err != nil { + return nil, err + } + bodyStart = start + var documentNode yaml.Node + if err := yaml.Unmarshal(region.data, &documentNode); err != nil { + return nil, fmt.Errorf("invalid profile front matter: %w", err) + } + if len(documentNode.Content) != 0 { + frontmatter, err = yamlSourceNode(region.data, documentNode.Content[0], region.startOffset) + if err != nil { + return nil, err + } + } + } else { + bodyStart = bomLength + } + bodyData := data[bodyStart:] + body := markdownTextNode(bodyData, bodyStart, len(data)) + sections := &sourceNode{items: markdownSections(bodyData), collection: true} + offsetSourceSpans(sections, bodyStart) + markers, err := markdownMarkers(bodyData, bodyStart) + if err != nil { + return nil, err + } + return &sourceNode{fields: map[string]*sourceNode{ + "frontmatter": frontmatter, + "body": body, + "sections": sections, + "markers": markers, + }}, nil +} + +func markdownMarkers(data []byte, base int) (*sourceNode, error) { + const beginPrefix = ""): + if openID != "" { + return nil, fmt.Errorf("nested LaunchDarkly marker %q", openID) + } + openID = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(line, beginPrefix), "-->")) + bodyStart = lineEnd + case strings.HasPrefix(line, endPrefix) && strings.HasSuffix(line, "-->"): + id := strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(line, endPrefix), "-->")) + if openID == "" || id != openID { + return nil, fmt.Errorf("LaunchDarkly marker %q has no matching begin marker", id) + } + body := markdownTextNode(data[bodyStart:offset], base+bodyStart, base+offset) + items = append(items, &sourceNode{fields: map[string]*sourceNode{ + "id": {value: id}, "body": body, + }}) + openID = "" + } + offset = lineEnd + } + if openID != "" { + return nil, fmt.Errorf("LaunchDarkly marker %q is not closed", openID) + } + return &sourceNode{items: items, collection: true}, nil +} + +func offsetSourceSpans(node *sourceNode, offset int) { + if node == nil { + return + } + if node.start != 0 || node.end != 0 { + node.start += offset + node.end += offset + } + for _, child := range node.fields { + offsetSourceSpans(child, offset) + } + for _, child := range node.items { + offsetSourceSpans(child, offset) + } +} diff --git a/internal/sync/reference/testdata/profiles/agents-instructions.md.golden b/internal/sync/reference/testdata/profiles/agents-instructions.md.golden new file mode 100644 index 00000000..28d9f467 --- /dev/null +++ b/internal/sync/reference/testdata/profiles/agents-instructions.md.golden @@ -0,0 +1,13 @@ +# Repository guide + +## Support + +Ask for an order ID. + + +Explain the refund policy. + + +## Build + +Run `make test`. diff --git a/internal/sync/reference/testdata/profiles/claude-instructions.md.golden b/internal/sync/reference/testdata/profiles/claude-instructions.md.golden new file mode 100644 index 00000000..f4a10263 --- /dev/null +++ b/internal/sync/reference/testdata/profiles/claude-instructions.md.golden @@ -0,0 +1,10 @@ +--- +name: support +description: Support subagent instructions +tools: Read, Grep +model: sonnet +--- + +# Support subagent + +Ask for an order ID and follow the support policy. diff --git a/internal/sync/reference/testdata/profiles/copilot.instructions.md b/internal/sync/reference/testdata/profiles/copilot.instructions.md new file mode 100644 index 00000000..024f8f36 --- /dev/null +++ b/internal/sync/reference/testdata/profiles/copilot.instructions.md @@ -0,0 +1,7 @@ +--- +applyTo: "src/support/**/*.ts" +--- + +# Support + +Use the support policy. diff --git a/internal/sync/reference/testdata/profiles/cursor-rule.mdc b/internal/sync/reference/testdata/profiles/cursor-rule.mdc new file mode 100644 index 00000000..6eef5da2 --- /dev/null +++ b/internal/sync/reference/testdata/profiles/cursor-rule.mdc @@ -0,0 +1,9 @@ +--- +description: Support instructions +globs: "src/support/**/*.ts" +alwaysApply: false +--- + +# Support + +Ask for an order ID before discussing an order.