Conversation
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>
5c87375 to
0877dd9
Compare
|
This, importantly, still allows you to $ 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 |
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>
There was a problem hiding this comment.
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$2as the target path relative to the selected Walkfile directory. - Add delegation semantics via exit code
200to 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
$2semantics, 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.
| // All Walkfiles returned fallback - return the last error | ||
| return nil, lastErr |
There was a problem hiding this comment.
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.
| // 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)) |
| // 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 | ||
| } |
There was a problem hiding this comment.
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.
| // 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) |
There was a problem hiding this comment.
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.
| * 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 | ||
| ``` |
There was a problem hiding this comment.
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.
| rel, _ := filepath.Rel(dir, t.path) | ||
| return rel |
There was a problem hiding this comment.
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.
| 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 |
| 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 |
There was a problem hiding this comment.
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.
| 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 | ||
|
|
There was a problem hiding this comment.
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.
Summary
$2) is now the path relative to the Walkfile's directoryExample
With a single
Walkfileat the project root:You can now build
src/foo.owithout needing a Walkfile insrc/.Test plan
RuleFileinheritanceFixes #24
🤖 Generated with Claude Code