Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
18 changes: 18 additions & 0 deletions docs/reference/search-paths.md
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,24 @@ For each rule file `rule-name.md`, the CLI looks for `rule-name-bootstrap` in th
./jira-context-bootstrap ← Wrong directory
```

### Bootstrap failure handling

Rule files are discovered and expanded for one rule directory before bootstrap
scripts from that directory run. Bootstrap processing completes before discovery
moves to the next rule directory, preserving search-path order.

- **Strict search paths:** A rule bootstrap failure is fatal and `Run` returns an
error. Rules after the failing bootstrap are not published. A rule parse or
expansion error is also fatal and `Run` returns an error before any bootstrap
scripts from that directory run.
- **Lenient search paths:** A rule bootstrap failure logs a warning, excludes only
the failing rule, and continues with later rules in the same directory.

A rule parse or expansion error still stops discovery of the remainder of that
directory. On a lenient path, rules discovered before that error remain eligible
for bootstrap and publication, and processing continues with the next rule
directory or search path.

## Working Directory

The `-C` option changes the working directory, which is automatically added to the search paths:
Expand Down
158 changes: 120 additions & 38 deletions pkg/codingcontext/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,11 +116,11 @@ func parseNamespacedTaskName(taskName string) (string, string, error) {
// New creates a new Context with the given options.
func New(opts ...Option) *Context {
c := &Context{
params: make(taskparser.Params),
includes: make(selectors.Selectors),
rules: make([]markdown.Markdown[markdown.RuleFrontMatter], 0),
skills: skills.AvailableSkills{Skills: make([]skills.Skill, 0)},
logger: slog.New(slog.NewTextHandler(os.Stderr, nil)),
params: make(taskparser.Params),
includes: make(selectors.Selectors),
rules: make([]markdown.Markdown[markdown.RuleFrontMatter], 0),
skills: skills.AvailableSkills{Skills: make([]skills.Skill, 0)},
logger: slog.New(slog.NewTextHandler(os.Stderr, nil)),
doBootstrap: true, // Default to true for backward compatibility
includeByDefault: true, // Default to true for backward compatibility
cmdRunner: func(cmd *exec.Cmd) error {
Expand All @@ -144,6 +144,28 @@ func nameFromPath(path string) string {

type markdownVisitor func(path string, fm *markdown.BaseFrontMatter) error

// A ruleFileError names the rule file involved in a discovery or bootstrap failure.
type ruleFileError struct {
path string
err error
}

func (e *ruleFileError) Error() string {
return fmt.Sprintf("%s: %s", e.path, e.err)
}

func (e *ruleFileError) Unwrap() error {
return e.err
}

// pendingRule is a parsed, expanded rule that has not been bootstrapped yet.
type pendingRule struct {
path string
md markdown.Markdown[markdown.RuleFrontMatter]
reason string // selector match explanation
tokens int
}

// Run executes the context assembly for the given taskName and returns the assembled result.
// The taskName is looked up in task search paths and its content is parsed into blocks.
// If the taskName cannot be found as a task file, an error is returned.
Expand Down Expand Up @@ -803,8 +825,12 @@ func (cc *Context) cleanupDownloadedDirectories() {
}
}

// findExecuteRuleFiles discovers rule files in each rule directory, then
// bootstraps and publishes them. Discovery of one directory finishes before
// any of that directory's bootstrap scripts run. A lenient bootstrap failure
// skips only that rule; a parse or expand error still stops discovery of the
// rest of that directory.
func (cc *Context) findExecuteRuleFiles(ctx context.Context) error {
// Skip rule file discovery if bootstrap is disabled
if !cc.doBootstrap {
return nil
}
Expand All @@ -813,51 +839,107 @@ func (cc *Context) findExecuteRuleFiles(ctx context.Context) error {
return namespacedRuleSearchPaths(dir, cc.namespace)
}

err := cc.visitMarkdownFiles(namespacedRulePaths, func(path string, baseFm *markdown.BaseFrontMatter) error {
var frontmatter markdown.RuleFrontMatter
for _, sp := range cc.downloadedPaths {
for _, dir := range namespacedRulePaths(sp.Path) {
var pending []pendingRule

md, err := markdown.ParseMarkdownFileWithLogger(path, &frontmatter, cc.logger)
if err != nil {
return fmt.Errorf("failed to parse markdown file %s: %w", path, err)
}
discoveryErr := cc.visitMarkdownInDir(dir, func(path string, baseFm *markdown.BaseFrontMatter) error {
rule, err := cc.discoverPendingRule(path, baseFm)
if err != nil {
return err
}

if cc.lintCollector != nil {
cc.lintCollector.recordFile(path, LoadedFileKindRule)
}
pending = append(pending, rule)

if frontmatter.Name == "" {
frontmatter.Name = nameFromPath(path)
}
return nil
})
if discoveryErr != nil {
if !sp.Lenient {
return discoveryErr
}

// Expand parameters only if expand is not explicitly set to false
var processedContent string
if shouldExpandParams(frontmatter.ExpandParams) {
processedContent, err = cc.expandParams(md.Content, nil)
if err != nil {
return fmt.Errorf("failed to expand parameters in file %s: %w", path, err)
cc.logger.Warn("stopping rule discovery after error", "path", dir, "error", discoveryErr)
}

if err := cc.bootstrapAndPublishRules(ctx, pending, sp.Lenient); err != nil {
return err
}
} else {
processedContent = md.Content
}
}

tokens := tokencount.EstimateTokens(processedContent)
return nil
}

// discoverPendingRule parses and expands a rule file without running its bootstrap script.
func (cc *Context) discoverPendingRule(
path string,
baseFm *markdown.BaseFrontMatter,
) (pendingRule, error) {
var frontmatter markdown.RuleFrontMatter

cc.rules = append(cc.rules, markdown.FromContent(frontmatter, processedContent))
md, err := markdown.ParseMarkdownFileWithLogger(path, &frontmatter, cc.logger)
if err != nil {
return pendingRule{}, &ruleFileError{path: path, err: fmt.Errorf("parse markdown file: %w", err)}
}

cc.totalTokens += tokens
if cc.lintCollector != nil {
cc.lintCollector.recordFile(path, LoadedFileKindRule)
}

// Get match reason to explain why this rule was included
_, reason := cc.includes.MatchesIncludes(*baseFm, cc.includeByDefault)
cc.logger.Info("Including rule file", "path", path, "reason", reason, "tokens", tokens)
if frontmatter.Name == "" {
frontmatter.Name = nameFromPath(path)
}

if err := cc.runBootstrapScript(ctx, path, frontmatter.Bootstrap); err != nil {
return fmt.Errorf("failed to run bootstrap script: %w", err)
processedContent := md.Content
if shouldExpandParams(frontmatter.ExpandParams) {
processedContent, err = cc.expandParams(md.Content, nil)
if err != nil {
return pendingRule{}, &ruleFileError{path: path, err: fmt.Errorf("expand parameters: %w", err)}
}
}

return nil
})
if err != nil {
return fmt.Errorf("failed to find and execute rule files: %w", err)
tokens := tokencount.EstimateTokens(processedContent)
_, reason := cc.includes.MatchesIncludes(*baseFm, cc.includeByDefault)

return pendingRule{
path: path,
md: markdown.FromContent(frontmatter, processedContent),
reason: reason,
tokens: tokens,
}, nil
}

// bootstrapAndPublishRules runs each pending rule's bootstrap script and, on
// success, publishes the rule. When lenient is true, a bootstrap failure logs
// a warning and continues with the remaining rules.
func (cc *Context) bootstrapAndPublishRules(
ctx context.Context,
pending []pendingRule,
lenient bool,
) error {
for _, rule := range pending {
if err := cc.runBootstrapScript(ctx, rule.path, rule.md.FrontMatter.Bootstrap); err != nil {
if lenient {
cc.logger.Warn(
"skipping rule file after bootstrap failure",
"path", rule.path,
"error", err,
)

continue
}

return &ruleFileError{path: rule.path, err: fmt.Errorf("run bootstrap script: %w", err)}
}

cc.rules = append(cc.rules, rule.md)
cc.totalTokens += rule.tokens
cc.logger.Info(
"Including rule file",
"path", rule.path,
"reason", rule.reason,
"tokens", rule.tokens,
)
}

return nil
Expand Down
Loading
Loading