Skip to content

Add Walkfile inheritance#33

Open
ejholmes wants to merge 6 commits intomasterfrom
walkfile-inheritance
Open

Add Walkfile inheritance#33
ejholmes wants to merge 6 commits intomasterfrom
walkfile-inheritance

Conversation

@ejholmes
Copy link
Owner

@ejholmes ejholmes commented Mar 7, 2026

Summary

  • walk(1) now searches up the directory tree for a Walkfile, allowing a single Walkfile at the project root to handle targets in any subdirectory
  • The target name passed to the Walkfile ($2) is now the path relative to the Walkfile's directory
  • Local Walkfiles still take precedence over parent Walkfiles

Example

With a single Walkfile at the project root:

#!/bin/bash
phase=$1
target=$2

case $target in
  src/*.o)
    case $phase in
      deps) echo ${target%.o}.c ;;
      exec) gcc -c -o $target ${target%.o}.c ;;
    esac ;;
esac

You can now build src/foo.o without needing a Walkfile in src/.

Test plan

  • Added unit tests for RuleFile inheritance
  • Added end-to-end test verifying Walkfile execution with inheritance
  • Verified local Walkfiles override parent Walkfiles
  • All existing tests pass

Fixes #24

🤖 Generated with Claude Code

@ejholmes ejholmes marked this pull request as ready for review March 7, 2026 00:56
ejholmes and others added 3 commits March 6, 2026 17:01
walk(1) now searches up the directory tree for a Walkfile, allowing a
single Walkfile at the project root to handle targets in any subdirectory.

The target name passed to the Walkfile ($2) is now the path relative to
the Walkfile's directory (e.g. "subdir/foo.o" instead of just "foo.o").

Local Walkfiles still take precedence over parent Walkfiles.

Fixes #24

Co-Authored-By: Claude <noreply@anthropic.com>
Demonstrate the new Walkfile inheritance feature by removing
docs/Walkfile and handling docs/* targets in the root Walkfile.

Co-Authored-By: Claude <noreply@anthropic.com>
Add detailed breaking changes section and migration guide for
Walkfile inheritance feature.

Co-Authored-By: Claude <noreply@anthropic.com>
@ejholmes ejholmes force-pushed the walkfile-inheritance branch from 5c87375 to 0877dd9 Compare March 7, 2026 01:01
@ejholmes
Copy link
Owner Author

ejholmes commented Mar 7, 2026

This, importantly, still allows you to cd down into a directory, execute walk in it, and still get the same result. For example:

$ walk docs
ok      man/walk.1.md
ok      bundled
ok      man/walk.1
ok      man/walk.1.html
ok      docs/index.html
ok      docs/all
ok      docs

$ cd docs
$ walk
ok      ../man/walk.1.md
ok      ../bundled
ok      ../man/walk.1
ok      ../man/walk.1.html
ok      index.html
ok      all

ejholmes and others added 3 commits March 6, 2026 17:13
A local Walkfile can now delegate to a parent Walkfile by exiting
with code 127. This enables composition where a local Walkfile
handles specific targets while inheriting generic rules from parent.

Example:
  case $target in
    special) ;; # handle locally
    *) exit 127 ;; # delegate to parent
  esac

Co-Authored-By: Claude <noreply@anthropic.com>
Exit code 127 is "command not found" in shells, which could be
triggered accidentally if a command inside the Walkfile fails.
Using 200 is safer - it won't conflict with common error codes.

Co-Authored-By: Claude <noreply@anthropic.com>
Move the generic *.o compilation rule from test/110-compile/Walkfile
to test/Walkfile. The subdirectory Walkfile now delegates unknown
targets to the parent using exit 200.

This shows how inheritance + delegation enables:
- Generic rules in parent (*.o → compile .c)
- Specific rules in child (hello → link)
- Automatic fallback for unhandled targets

Co-Authored-By: Claude <noreply@anthropic.com>
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds Walkfile inheritance so walk(1) can resolve rules from parent directories, enabling a single project-root Walkfile to build targets in subdirectories while still allowing local overrides.

Changes:

  • Implement Walkfile discovery up the directory tree (RuleFiles) and pass $2 as the target path relative to the selected Walkfile directory.
  • Add delegation semantics via exit code 200 to allow local Walkfiles to fall back to parent Walkfiles.
  • Update tests, man pages, repo Walkfiles, and changelog to reflect inheritance behavior.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
plan.go Implements RuleFiles() search and updates target execution to use the selected Walkfile’s directory + relative $2.
plan_test.go Adds unit/e2e coverage for inheritance and delegation behavior.
test/Walkfile Adds a generic */*.o compile rule intended to be inherited by subdirectories.
test/110-compile/Walkfile Delegates unknown targets to the parent Walkfile using exit code 200.
man/walk.1.md Documents inheritance, $2 semantics, and delegation exit code.
man/walk.1 Generated man page updates for inheritance/delegation docs.
man/walk.1.html Generated HTML man page updates for inheritance/delegation docs.
docs/index.html Updates docs site text to mention inheritance and new $2 semantics.
docs/Walkfile Removes docs-local Walkfile in favor of inherited root Walkfile rules.
Walkfile Adds root-level rules for docs/* targets now that docs can inherit the root Walkfile.
README.md Updates $2 description to reflect “path relative to the Walkfile”.
CHANGELOG.md Adds an “Unreleased” section describing inheritance and breaking changes.
Comments suppressed due to low confidence (2)

plan.go:307

  • Exec() doesn’t honor the delegation contract (ExitCodeDelegate=200). If the selected Walkfile exits 200 during the exec phase, walk should try the next Walkfile up the tree (as described in the man page), but Exec currently just returns the exit status. Consider mirroring the fallback loop used in Dependencies() (or reusing a shared helper) so delegation works consistently in both phases.
// Exec executes the rule with "exec" as the first argument.
func (t *target) Exec(ctx context.Context) error {
	// No .walk file, meaning it's a static dependency.
	if t.rulefile() == "" {
		return nil
	}

	cmd, err := t.ruleCommand(ctx, PhaseExec)
	if err != nil {
		return err
	}
	return cmd.Run()
}

docs/index.html:172

  • docs/index.html documents Walkfile inheritance and the new $2 semantics, but it doesn’t include the new delegation behavior (exit code 200) that’s now documented in the man page (man/walk.1.*). If docs/index.html is intended to mirror the man page, please add the delegation section here as well to avoid inconsistent user docs.
<p><a class="man-ref" href="http://ejholmes.github.io/walk">walk<span class="s">(1)</span></a> delegates to an executable file called <a href="#WALKFILE" title="WALKFILE" data-bare-link="true">Walkfile</a> to determine
what dependencies the target has, and how to execute it. <a class="man-ref" href="http://ejholmes.github.io/walk">walk<span class="s">(1)</span></a> searches for a
Walkfile starting from the target's directory and walking up the directory tree
until one is found. This allows a single Walkfile at the project root to handle
targets in any subdirectory.</p>

<h2 id="WALKFILE">WALKFILE</h2>

<p>The <code>Walkfile</code> determines <em>how</em> a target is executed, and what other targets it
depends on.</p>

<p>When <a class="man-ref" href="http://ejholmes.github.io/walk">walk<span class="s">(1)</span></a> begins execution of a target, it searches for an executable file
called <code>Walkfile</code> starting from the target's directory and walking up the
directory tree. Once found, it executes the Walkfile with the following
positional arguments:</p>

<dl>
<dt><code>$1</code></dt>
<dd>The <a href="#PHASES" title="PHASES" data-bare-link="true">phase</a> (<code>deps</code> or <code>exec</code>).</dd>
<dt><code>$2</code></dt>
<dd>The target path relative to the Walkfile's directory (e.g. <code>hello.o</code> or
<code>subdir/hello.o</code> if the Walkfile is in a parent directory).</dd>
</dl>

<p>It's up to the <code>Walkfile</code> to determine what dependencies the target has, and
how to execute it.</p>


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +364 to +365
// All Walkfiles returned fallback - return the last error
return nil, lastErr
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When all candidate Walkfiles exit with ExitCodeDelegate (200), Dependencies() returns the last *exec.ExitError (exit status 200). That error is not very actionable for users and doesn’t indicate that no Walkfile handled the target. Consider returning a clearer sentinel error (e.g., “no rule found for target” / “all Walkfiles delegated”) and/or including the target name + Walkfile chain in the message.

Suggested change
// All Walkfiles returned fallback - return the last error
return nil, lastErr
// All Walkfiles returned fallback - construct a clearer sentinel error
if lastErr != nil {
return nil, fmt.Errorf("no rule found for target %q (all %d Walkfiles delegated): %w", t.targetName(), len(t.rulefiles), lastErr)
}
return nil, fmt.Errorf("no rule found for target %q (all %d Walkfiles delegated)", t.targetName(), len(t.rulefiles))

Copilot uses AI. Check for mistakes.
Comment on lines +97 to 118
// Use a temp directory that truly has no Walkfile anywhere in its ancestry
tmpdir := t.TempDir()
targetPath := filepath.Join(tmpdir, "all")

b := new(bytes.Buffer)
plan := newPlan()
plan.NewTarget = NewTarget(TargetOptions{
Stdout: b,
WorkingDir: tmpdir,
Stdout: b,
})
err := plan.Plan(ctx, "test/000-no-walkfile/all")
err := plan.Plan(ctx, "all")
assert.NoError(t, err)

err = plan.Exec(ctx, NewSemaphore(0))
assert.NoError(t, err)

// If there's no Walkfile in the directory, it might just be a static
// If there's no Walkfile in the directory (or any parent), it's a static
// file. We don't really need to show these in output.
assert.Equal(t, "", b.String())

_ = targetPath // silence unused warning
}
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test defines targetPath but never uses it (aside from _ = targetPath). It looks like leftover scaffolding from the earlier version of the test; consider removing targetPath entirely to keep the test focused.

Copilot uses AI. Check for mistakes.
Comment on lines +244 to +257
// Parent Walkfile: handles *.o
parentWalkfile := filepath.Join(tmpdir, "Walkfile")
err := os.WriteFile(parentWalkfile, []byte(`#!/bin/bash
phase=$1
target=$2

case $target in
*.o)
case $phase in
exec) touch "$target.from-parent" ;;
esac ;;
*) exit 200 ;;
esac
`), 0755)
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the parent Walkfile used by this test, the case pattern *.o) likely won’t match delegated targets like subdir/foo.o (shell case patterns typically don’t let * match /). This can cause the fallback path to delegate again and ultimately fail with exit status 200. Use a pattern that matches paths (e.g., */*.o) or similar) so the test actually exercises parent handling.

Copilot uses AI. Check for mistakes.
Comment on lines +7 to +15
* Walkfile inheritance: walk(1) now searches up the directory tree for a Walkfile, allowing a single Walkfile at the project root to handle targets in any subdirectory. This eliminates the need to create a Walkfile in every directory.

* Walkfile fallback (exit code 200): A local Walkfile can delegate to a parent Walkfile by exiting with code 127. This enables composition where a local Walkfile handles specific targets while inheriting generic rules from a parent:
```bash
case $target in
special) ;; # handle locally
*) exit 200 ;; # delegate to parent
esac
```
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This changelog entry says delegation is “exit code 200” but then says “exiting with code 127”. That’s contradictory and doesn’t match ExitCodeDelegate=200 elsewhere in the PR. Please correct the text to consistently use 200.

Copilot uses AI. Check for mistakes.
Comment on lines +284 to +285
rel, _ := filepath.Rel(dir, t.path)
return rel
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

targetName() ignores the error from filepath.Rel. If Rel fails (e.g., on Windows across volumes, or with certain path edge-cases), this returns an empty/incorrect target name and rule execution will break. Handle the error explicitly (e.g., fall back to t.name or t.path) instead of discarding it.

Suggested change
rel, _ := filepath.Rel(dir, t.path)
return rel
rel, err := filepath.Rel(dir, t.path)
if err == nil {
return rel
}
if t.name != "" {
return t.name
}
return t.path

Copilot uses AI. Check for mistakes.
Comment on lines +233 to +241
func TestRuleFile_Fallback(t *testing.T) {
// Test that a local Walkfile can delegate to parent by exiting with code 127
// Structure:
// tmpdir/
// Walkfile <- handles *.o generically
// subdir/
// Walkfile <- handles "special" only, exits 127 for others
// foo.o <- should fall back to parent's *.o rule
// special <- handled by local Walkfile
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment mentions exit code 127, but the implementation/docs use 200 (ExitCodeDelegate). Please update the comment to match the actual delegation exit code to avoid confusion.

Copilot uses AI. Check for mistakes.
Comment on lines +63 to +97
walk(1) delegates to an executable file called [Walkfile][WALKFILE] to determine
what dependencies the target has, and how to execute it. walk(1) searches for a
Walkfile starting from the target's directory and walking up the directory tree
until one is found. This allows a single Walkfile at the project root to handle
targets in any subdirectory.

## WALKFILE

The `Walkfile` determines _how_ a target is executed, and what other targets it
depends on.

When walk(1) begins execution of a target, it attempts to find an executable
file called `Walkfile` in the same directory as the target, and then executes
it with the following positional arguments:
When walk(1) begins execution of a target, it searches for an executable file
called `Walkfile` starting from the target's directory and walking up the
directory tree. Once found, it executes the Walkfile with the following
positional arguments:

* `$1`:
The [phase][PHASES] (`deps` or `exec`).

* `$2`:
The name of the target to build (e.g. `hello.o`).
The target path relative to the Walkfile's directory (e.g. `hello.o` or
`subdir/hello.o` if the Walkfile is in a parent directory).

It's up to the `Walkfile` to determine what dependencies the target has, and
how to execute it.

If a Walkfile exits with code `200`, walk(1) will try the next Walkfile up the
directory tree. This allows a local Walkfile to handle specific targets while
delegating unknown targets to a parent Walkfile:

case $target in
special) ;; # handle locally
*) exit 200 ;; # delegate to parent
esac

Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new inheritance/$2 text is added, but later in this man page the PHASES/EXAMPLES sections still say dependency paths are “relative to the target” and that targets are executed “relative to the directory of the target”. With inheritance, dependency interpretation/execution are now relative to the selected Walkfile’s directory. Please update the later sections to avoid contradicting the new behavior.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Ability to use a single Walkfile

2 participants