Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
bfa890c
feat(template): restrict modifications to working_dir
dangazineu Jun 7, 2025
2501ef3
task description
dangazineu Jun 7, 2025
97910e8
docs: add implementation plan to TODO_VYB.md
dangazineu Jun 7, 2025
b226b7c
more task clarification
dangazineu Jun 8, 2025
05d241d
docs(todo): mark ExecutionContext struct as implemented
dangazineu Jun 8, 2025
7a31979
refactor: integrate ExecutionContext in template prepareExecutionContext
dangazineu Jun 8, 2025
edbb856
refactor(selector): update Select to use ExecutionContext
dangazineu Jun 8, 2025
5c37f1d
fix(selector): Walk entire project root to collect all exclusions
dangazineu Jun 8, 2025
918be5d
refactor(template): enforce write-scope restrictions using ExecutionC…
dangazineu Jun 8, 2025
72ff541
adjusted task list to clarify intent
dangazineu Jun 8, 2025
0391602
feat(template): integrate module context into user prompts
dangazineu Jun 8, 2025
f2af0a5
cleaned commented out code and improved next task
dangazineu Jun 8, 2025
0a0312a
feat(cmd/template): add --all flag to include sub-module files
dangazineu Jun 8, 2025
a8505c5
registered -all flag in the template
dangazineu Jun 8, 2025
04b08a5
test(selector): ensure files outside target_dir excluded
dangazineu Jun 8, 2025
c4d9f55
added a step in the task list
dangazineu Jun 8, 2025
d2600b0
fixed numbering in the task list
dangazineu Jun 8, 2025
59ab3ee
added more tasks
dangazineu Jun 8, 2025
c072e3b
feat(cmd/template): merge metadata with fresh filesystem snapshot to …
dangazineu Jun 8, 2025
1ecff8a
fix FindModule root handling and add unit test
dangazineu Jun 8, 2025
8ec38b0
removed vyb task list
dangazineu Jun 8, 2025
6c0ef9a
Fixed comment placement
dangazineu Jun 8, 2025
eb33b79
removed unused func return value
dangazineu Jun 8, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
169 changes: 136 additions & 33 deletions cmd/template/template.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"github.com/cbroglie/mustache"
"github.com/dangazineu/vyb/llm/openai"
"github.com/dangazineu/vyb/llm/payload"
"github.com/dangazineu/vyb/workspace/context"
"github.com/dangazineu/vyb/workspace/matcher"
"github.com/dangazineu/vyb/workspace/project"
"github.com/dangazineu/vyb/workspace/selector"
Expand Down Expand Up @@ -54,66 +55,73 @@ type Definition struct {
LongDescription string `yaml:"longDescription"`
}

// prepareExecutionContext extracts all the logic needed to prepare the file selection context.
func prepareExecutionContext(target *string) (absRoot string, relWorkDir string, relTarget *string, err error) {
// prepareExecutionContext builds and validates an ExecutionContext based on
// the current working directory and an optional *target* argument.
func prepareExecutionContext(target *string) (*context.ExecutionContext, error) {
absWorkingDir, err := filepath.Abs(".")
if err != nil {
return "", "", nil, fmt.Errorf("failed to determine absolute path of working directory: %w", err)
}
distToRoot, err := project.FindDistanceToRoot(absWorkingDir)
if err != nil {
return "", "", nil, fmt.Errorf("unable to determine project distToRoot: %w", err)
return nil, fmt.Errorf("failed to determine absolute working dir: %w", err)
}

absRoot, err = filepath.Abs(distToRoot)
// Locate project root using existing helper.
distToRoot, err := project.FindDistanceToRoot(absWorkingDir)
if err != nil {
return "", "", nil, fmt.Errorf("failed to determine absolute path of project distToRoot %s: %w", distToRoot, err)
return nil, fmt.Errorf("unable to determine project root: %w", err)
}

relWorkDir, err = filepath.Rel(absRoot, absWorkingDir)
absRoot, err := filepath.Abs(distToRoot)
if err != nil {
return "", "", nil, fmt.Errorf("failed to determine relative path: %w", err)
return nil, fmt.Errorf("failed to determine absolute project root: %w", err)
}

// Resolve absolute target (if any).
var absTarget *string
if target != nil {
absTarget, err := filepath.Abs(*target)
if err != nil {
return "", "", nil, fmt.Errorf("failed to determine absolute path of target %s: %w", *target, err)
}

rel, err := filepath.Rel(absRoot, absTarget)
at, err := filepath.Abs(*target)
if err != nil {
return "", "", nil, fmt.Errorf("failed to determine relative path: %w", err)
}

if rel == "" || strings.HasPrefix(rel, "..") {
return "", "", nil, fmt.Errorf("the target file %s is outside the project distToRoot %s", absTarget, absRoot)
return nil, fmt.Errorf("failed to resolve target %s: %w", *target, err)
}
absTarget = &at
}

info, err := os.Stat(absTarget)
if err != nil || info.IsDir() {
return "", "", nil, fmt.Errorf("the target %s is not a valid file", absTarget)
}
target = &rel
// Let ExecutionContext enforce invariants.
ec, err := context.NewExecutionContext(absRoot, absWorkingDir, absTarget)
if err != nil {
return nil, err
}
return absRoot, relWorkDir, target, nil
return ec, nil
}

func execute(cmd *cobra.Command, args []string, def *Definition) error {
if len(def.ArgInclusionPatterns) == 0 && len(args) > 0 {
return fmt.Errorf("command \"%s\" expects no arguments, but got %v", cmd.Use, args)
}

// ---------------------------
// Retrieve --all flag value.
// ---------------------------
includeAll, _ := cmd.Flags().GetBool("all")

var target *string
if len(args) > 0 {
target = &args[0]
}

absRoot, relWorkDir, relTarget, err := prepareExecutionContext(target)
ec, err := prepareExecutionContext(target)
if err != nil {
return err
}

absRoot := ec.ProjectRoot

// relTarget is the *file* provided by the user (if any), relative to root.
var relTarget *string
if target != nil {
absTarget, _ := filepath.Abs(*target)
rt, _ := filepath.Rel(absRoot, absTarget)
relTarget = &rt
}

rootFS := os.DirFS(absRoot)

if relTarget != nil {
Expand All @@ -122,11 +130,55 @@ func execute(cmd *cobra.Command, args []string, def *Definition) error {
}
}

files, err := selector.Select(rootFS, relWorkDir, relTarget, append(systemExclusionPatterns, def.ArgExclusionPatterns...), def.ArgInclusionPatterns)
files, err := selector.Select(rootFS, ec, append(systemExclusionPatterns, def.ArgExclusionPatterns...), def.ArgInclusionPatterns)
if err != nil {
return err
}

// ------------------------------------------------------------
// Load stored metadata (with annotations) and merge with a fresh
// snapshot produced from the current filesystem state. This
// guarantees we operate with up-to-date file information while
// keeping previously generated annotations intact.
// ------------------------------------------------------------
storedMeta, err := project.LoadMetadata(absRoot)
if err != nil {
return err
}
freshMeta, err := project.BuildMetadataFS(rootFS)
if err != nil {
return err
}

// Validate that the module name sets are identical.
if !equalModuleNameSets(storedMeta.Modules, freshMeta.Modules) {
return fmt.Errorf("module hierarchy mismatch between stored metadata and filesystem snapshot – please run 'vyb update' first")
}

// Merge – keep annotations from storedMeta, replace structure from freshMeta.
storedMeta.Patch(freshMeta)
meta := storedMeta

// ------------------------------------------------------------
// Unless --all is provided, filter out files that belong to
// descendant modules of the target module (i.e. keep only files
// whose module == targetModule).
// ------------------------------------------------------------
if !includeAll && meta.Modules != nil {
relTargetDir, _ := filepath.Rel(absRoot, ec.TargetDir)
relTargetDir = filepath.ToSlash(relTargetDir)
targetModule := project.FindModule(meta.Modules, relTargetDir)
if targetModule != nil {
var filtered []string
for _, f := range files {
if project.FindModule(meta.Modules, f) == targetModule {
filtered = append(filtered, f)
}
}
files = filtered
}
}

fmt.Printf("The following files will be included in the request:\n")
for _, file := range files {
if relTarget != nil && file == *relTarget {
Expand All @@ -136,7 +188,7 @@ func execute(cmd *cobra.Command, args []string, def *Definition) error {
}
}

userMsg, err := payload.BuildUserMessage(rootFS, files)
userMsg, err := buildExtendedUserMessage(rootFS, meta, ec, files)
if err != nil {
return err
}
Expand All @@ -159,13 +211,34 @@ func execute(cmd *cobra.Command, args []string, def *Definition) error {
return err
}

// --------------------------------------------------------
// Validate that every file in the proposal is allowed to be modified.
// --------------------------------------------------------
invalidFiles := []string{}

// helper closure to assert path containment using absolute paths.
isWithinDir := func(dir, candidate string) bool {
dir = filepath.Clean(dir)
candidate = filepath.Clean(candidate)
if dir == candidate {
return true
}
return strings.HasPrefix(candidate, dir+string(os.PathSeparator))
}

for _, prop := range proposal.Proposals {
// 1. Pattern based validation (existing behaviour).
if !matcher.IsIncluded(rootFS, prop.FileName, append(systemExclusionPatterns, def.ModificationExclusionPatterns...), def.ModificationInclusionPatterns) {
invalidFiles = append(invalidFiles, prop.FileName)
continue
}
// 2. Must reside within the working_dir using absolute paths.
absProp := filepath.Join(absRoot, prop.FileName)
if !isWithinDir(ec.WorkingDir, absProp) {
invalidFiles = append(invalidFiles, prop.FileName+" (outside working_dir)")
}
}

if len(invalidFiles) > 0 {
return fmt.Errorf("change proposal contains modifications to unallowed files: %v", invalidFiles)
}
Expand Down Expand Up @@ -211,14 +284,44 @@ func Register(rootCmd *cobra.Command) error {
// Register subcommands.
defs := load()
for _, def := range defs {
rootCmd.AddCommand(&cobra.Command{
cmd := &cobra.Command{
Use: def.Name,
Long: def.LongDescription,
Short: def.ShortDescription,
RunE: func(cmd *cobra.Command, args []string) error {
return execute(cmd, args, def)
},
})
}
cmd.Flags().BoolP("all", "a", false, "include all files, even those in descendant modules")
rootCmd.AddCommand(cmd)
}
return nil
}

// collectModuleNames flattens a module tree into a set of names.
func collectModuleNames(m *project.Module, set map[string]struct{}) {
if m == nil {
return
}
set[m.Name] = struct{}{}
for _, c := range m.Modules {
collectModuleNames(c, set)
}
}

// equalModuleNameSets returns true when both module trees enumerate exactly
// the same set of module names.
func equalModuleNameSets(a, b *project.Module) bool {
sa, sb := map[string]struct{}{}, map[string]struct{}{}
collectModuleNames(a, sa)
collectModuleNames(b, sb)
if len(sa) != len(sb) {
return false
}
for k := range sa {
if _, ok := sb[k]; !ok {
return false
}
}
return true
}
117 changes: 117 additions & 0 deletions cmd/template/user_msg_builder.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package template

import (
"fmt"
"io/fs"
"path/filepath"
"strings"

"github.com/dangazineu/vyb/llm/payload"
"github.com/dangazineu/vyb/workspace/context"
"github.com/dangazineu/vyb/workspace/project"
)

// buildExtendedUserMessage composes the user-message payload that will be
// sent to the LLM. It prepends module context information — as dictated
// by the specification — before the raw file contents. When metadata is
// nil or when any contextual information is missing the function falls
// back gracefully, emitting only what is available.
func buildExtendedUserMessage(rootFS fs.FS, meta *project.Metadata, ec *context.ExecutionContext, filePaths []string) (string, error) {
// If metadata is missing we revert to the original behaviour – emit
// just the files.
if meta == nil || meta.Modules == nil {
return payload.BuildUserMessage(rootFS, filePaths)
}

// Helper to clean/normalise relative paths.
rel := func(abs string) string {
if abs == "" {
return ""
}
r, _ := filepath.Rel(ec.ProjectRoot, abs)
return filepath.ToSlash(r)
}

workingRel := rel(ec.WorkingDir)
targetRel := rel(ec.TargetDir)

workingMod := project.FindModule(meta.Modules, workingRel)
targetMod := project.FindModule(meta.Modules, targetRel)

if workingMod == nil || targetMod == nil {
return "", fmt.Errorf("failed to locate working and target modules")
}

var sb strings.Builder

// ------------------------------------------------------------
// 1. External context of working module.
// ------------------------------------------------------------
if ann := workingMod.Annotation; ann != nil && ann.ExternalContext != "" {
sb.WriteString(fmt.Sprintf("# Module: `%s`\n", workingMod.Name))
sb.WriteString("## External Context\n")
sb.WriteString(ann.ExternalContext + "\n")
}

// ------------------------------------------------------------
// 2. Internal context of modules between working and target.
// ------------------------------------------------------------
for m := targetMod.Parent; m != nil && m != workingMod; m = m.Parent {
if ann := m.Annotation; ann != nil && ann.InternalContext != "" {
sb.WriteString(fmt.Sprintf("# Module: `%s`\n", m.Name))
sb.WriteString("## Internal Context\n")
sb.WriteString(ann.InternalContext + "\n")
}
}

// ------------------------------------------------------------
// 3. Public context of sibling modules along the path from the
// parent of the target module up to (and including) the working
// module. This replaces the previous logic that only considered
// direct children of the working module.
// ------------------------------------------------------------

isAncestor := func(a, b string) bool {
return a == b || (a != "." && strings.HasPrefix(b, a+"/"))
}

for ancestor := targetMod.Parent; ancestor != nil; ancestor = ancestor.Parent {
for _, child := range ancestor.Modules {
// Skip the target itself and all its ancestor path.
if isAncestor(child.Name, targetMod.Name) {
continue
}
if ann := child.Annotation; ann != nil && ann.PublicContext != "" {
sb.WriteString(fmt.Sprintf("# Module: `%s`\n", child.Name))
sb.WriteString("## Public Context\n")
sb.WriteString(ann.PublicContext + "\n")
}
}
if ancestor == workingMod {
break
}
}

// ------------------------------------------------------------
// 4. Public context of immediate sub-modules of target module.
// ------------------------------------------------------------
for _, child := range targetMod.Modules {
if ann := child.Annotation; ann != nil && ann.PublicContext != "" {
sb.WriteString(fmt.Sprintf("# Module: `%s`\n", child.Name))
sb.WriteString("## Public Context\n")
sb.WriteString(ann.PublicContext + "\n")
}
}

// ------------------------------------------------------------
// 5. Append file contents (only files from target module were
// selected by selector.Select).
// ------------------------------------------------------------
filesMsg, err := payload.BuildUserMessage(rootFS, filePaths)
if err != nil {
return "", err
}
sb.WriteString(filesMsg)

return sb.String(), nil
}
Loading