From 027f00a4ef5a73308ff412c540aeda094697f832 Mon Sep 17 00:00:00 2001 From: "Eric J. Holmes" Date: Fri, 6 Mar 2026 16:43:36 -0800 Subject: [PATCH 1/8] Add 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. 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 --- CHANGELOG.md | 6 +++ README.md | 2 +- man/walk.1 | 6 +-- man/walk.1.html | 18 +++++---- man/walk.1.md | 20 +++++---- plan.go | 46 ++++++++++++--------- plan_test.go | 105 ++++++++++++++++++++++++++++++++++++++++++++++-- 7 files changed, 162 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0376991..322d0eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +**Features** + +* 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. The target name passed to the Walkfile (`$2`) is now the path relative to the Walkfile's directory. + ## 0.3.3 (2017-09-20) **Improvements** diff --git a/README.md b/README.md index c4a87e8..b302feb 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Create an executable file called `Walkfile`: #!/bin/bash phase=$1 # "deps" or "exec" -target=$2 # the target name +target=$2 # the target path (relative to the Walkfile) case $target in hello) diff --git a/man/walk.1 b/man/walk.1 index 5b27f8f..dd493f5 100644 --- a/man/walk.1 +++ b/man/walk.1 @@ -33,17 +33,17 @@ By default, the stdout/stderr output from the \fBWalkfile\fR is prefixed with th .SH "TARGETS" Targets can be used to represent a task, or a file that needs to be built\. They are synonymous with targets in make(1)\. In general, targets are relative paths to files that need to be built, like \fBsrc/hello\.o\fR\. When a target does not relate to an actual file on disk, it's synonymous with \fB\.PHONY\fR targets in make(1)\. .P -walk(1) delegates to an executable file called \fIWalkfile\fR within the same directory as the target, to determine what dependencies the target has, and how to execute it\. +walk(1) delegates to an executable file called \fIWalkfile\fR 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\. .SH "WALKFILE" The \fBWalkfile\fR determines \fIhow\fR a target is executed, and what other targets it depends on\. .P -When walk(1) begins execution of a target, it attempts to find an executable file called \fBWalkfile\fR 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 \fBWalkfile\fR starting from the target's directory and walking up the directory tree\. Once found, it executes the Walkfile with the following positional arguments: .TP \fB$1\fR The \fIphase\fR (\fBdeps\fR or \fBexec\fR)\. .TP \fB$2\fR -The name of the target to build (e\.g\. \fBhello\.o\fR)\. +The target path relative to the Walkfile's directory (e\.g\. \fBhello\.o\fR or \fBsubdir/hello\.o\fR if the Walkfile is in a parent directory)\. .P It's up to the \fBWalkfile\fR to determine what dependencies the target has, and how to execute it\. .SH "PHASES" diff --git a/man/walk.1.html b/man/walk.1.html index 05f0aac..1832c9b 100644 --- a/man/walk.1.html +++ b/man/walk.1.html @@ -143,24 +143,28 @@

TARGETS

relate to an actual file on disk, it's synonymous with .PHONY targets in make(1).

-

walk(1) delegates to an executable file called Walkfile within the same -directory as the target, to determine what dependencies the target has, and how -to execute it.

+

walk(1) delegates to an executable file called 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 (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 diff --git a/man/walk.1.md b/man/walk.1.md index b6d9f98..bff3b01 100644 --- a/man/walk.1.md +++ b/man/walk.1.md @@ -60,24 +60,28 @@ to files that need to be built, like `src/hello.o`. When a target does not relate to an actual file on disk, it's synonymous with `.PHONY` targets in make(1). -walk(1) delegates to an executable file called [Walkfile][WALKFILE] within the same -directory as the target, to determine what dependencies the target has, and how -to execute it. +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. diff --git a/plan.go b/plan.go index 1214fba..00047c0 100644 --- a/plan.go +++ b/plan.go @@ -238,6 +238,10 @@ type target struct { // build file. dir string + // targetName is the target name relative to the Walkfile's directory. + // This is passed as $2 to the Walkfile. + targetName string + // The working directory. wd string @@ -251,16 +255,21 @@ func newTarget(wd, name string) *target { rulefile := RuleFile(path) var dir string + var targetName string if rulefile != "" { - dir = filepath.Dir(path) + // Use the Walkfile's directory as the working directory + dir = filepath.Dir(rulefile) + // Compute target name relative to the Walkfile's directory + targetName, _ = filepath.Rel(dir, path) } return &target{ - name: name, - path: path, - rulefile: rulefile, - dir: dir, - wd: wd, + name: name, + path: path, + rulefile: rulefile, + dir: dir, + targetName: targetName, + wd: wd, } } @@ -327,8 +336,7 @@ func (t *target) Dependencies(ctx context.Context) ([]string, error) { } func (t *target) ruleCommand(ctx context.Context, phase string) (*exec.Cmd, error) { - name := filepath.Base(t.path) - cmd := exec.CommandContext(ctx, t.rulefile, phase, name) + cmd := exec.CommandContext(ctx, t.rulefile, phase, t.targetName) cmd.Stdout = t.stdout cmd.Stderr = t.stderr cmd.Dir = t.dir @@ -363,20 +371,22 @@ func (t *verboseTarget) Exec(ctx context.Context) error { } // RuleFile is used to determine the path to an executable which will be used as -// the Rule to execute the given target. At the moment, this simply looks for an -// executable file called `Walkfile` in the same directory as the target. +// the Rule to execute the given target. It walks up the directory tree from the +// target's directory until it finds a Walkfile. func RuleFile(path string) string { dir := filepath.Dir(path) - try := []string{ - Walkfile, - } - for _, n := range try { - path := filepath.Join(dir, n) - _, err := os.Stat(path) - if err == nil { - return path + for { + walkfile := filepath.Join(dir, Walkfile) + if _, err := os.Stat(walkfile); err == nil { + return walkfile + } + + parent := filepath.Dir(dir) + if parent == dir { + break } + dir = parent } return "" diff --git a/plan_test.go b/plan_test.go index fc292d6..6018bc1 100644 --- a/plan_test.go +++ b/plan_test.go @@ -94,22 +94,27 @@ func TestPlan_Error(t *testing.T) { } func TestPlan_NoWalkfile(t *testing.T) { - clean(t) + // 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 } func TestPrefixWriter(t *testing.T) { @@ -161,6 +166,98 @@ prefix: to do the job in dark, airless conditions? `, b.String()) } +func TestRuleFile_Inheritance(t *testing.T) { + // Create a temp directory structure: + // tmpdir/ + // Walkfile <- should be found + // subdir/ + // target.txt <- target here, no local Walkfile + tmpdir := t.TempDir() + + walkfile := filepath.Join(tmpdir, "Walkfile") + err := os.WriteFile(walkfile, []byte("#!/bin/bash\necho test"), 0755) + assert.NoError(t, err) + + subdir := filepath.Join(tmpdir, "subdir") + err = os.Mkdir(subdir, 0755) + assert.NoError(t, err) + + targetPath := filepath.Join(subdir, "target.txt") + + // RuleFile should find the Walkfile in the parent directory + found := RuleFile(targetPath) + assert.Equal(t, walkfile, found) +} + +func TestRuleFile_Inheritance_EndToEnd(t *testing.T) { + // Create a temp directory structure with a Walkfile that handles subdirectory targets + tmpdir := t.TempDir() + + // Walkfile that echoes the target name for deps and creates a marker file on exec + walkfile := filepath.Join(tmpdir, "Walkfile") + err := os.WriteFile(walkfile, []byte(`#!/bin/bash +phase=$1 +target=$2 + +case $phase in + deps) ;; # no deps + exec) touch "$target.built" ;; +esac +`), 0755) + assert.NoError(t, err) + + // Create a subdirectory (no Walkfile here - should inherit) + subdir := filepath.Join(tmpdir, "subdir") + err = os.Mkdir(subdir, 0755) + assert.NoError(t, err) + + b := new(bytes.Buffer) + plan := newPlan() + plan.NewTarget = NewTarget(TargetOptions{ + WorkingDir: tmpdir, + Stdout: b, + }) + + // Build a target in the subdirectory + err = plan.Plan(ctx, "subdir/foo") + assert.NoError(t, err) + + err = plan.Exec(ctx, NewSemaphore(0)) + assert.NoError(t, err) + + // Verify the Walkfile was invoked and created the marker file + _, err = os.Stat(filepath.Join(tmpdir, "subdir/foo.built")) + assert.NoError(t, err, "expected subdir/foo.built to exist") +} + +func TestRuleFile_LocalOverridesParent(t *testing.T) { + // Create a temp directory structure: + // tmpdir/ + // Walkfile <- parent Walkfile + // subdir/ + // Walkfile <- local Walkfile (should be used) + // target.txt + tmpdir := t.TempDir() + + parentWalkfile := filepath.Join(tmpdir, "Walkfile") + err := os.WriteFile(parentWalkfile, []byte("#!/bin/bash\necho parent"), 0755) + assert.NoError(t, err) + + subdir := filepath.Join(tmpdir, "subdir") + err = os.Mkdir(subdir, 0755) + assert.NoError(t, err) + + localWalkfile := filepath.Join(subdir, "Walkfile") + err = os.WriteFile(localWalkfile, []byte("#!/bin/bash\necho local"), 0755) + assert.NoError(t, err) + + targetPath := filepath.Join(subdir, "target.txt") + + // RuleFile should find the local Walkfile first + found := RuleFile(targetPath) + assert.Equal(t, localWalkfile, found) +} + func clean(t testing.TB) { err := Exec(ctx, NewSemaphore(0), "test/clean") assert.NoError(t, err) From 139389f005048250fd45b948bf60722c75dc44da Mon Sep 17 00:00:00 2001 From: "Eric J. Holmes" Date: Fri, 6 Mar 2026 16:50:24 -0800 Subject: [PATCH 2/8] Remove docs/Walkfile using inheritance Demonstrate the new Walkfile inheritance feature by removing docs/Walkfile and handling docs/* targets in the root Walkfile. Co-Authored-By: Claude --- Walkfile | 19 ++++++++++++++++++- docs/Walkfile | 15 --------------- docs/index.html | 18 +++++++++++------- 3 files changed, 29 insertions(+), 23 deletions(-) delete mode 100755 docs/Walkfile diff --git a/Walkfile b/Walkfile index ddd3891..ad1a5c9 100755 --- a/Walkfile +++ b/Walkfile @@ -101,7 +101,24 @@ EOF exec) exec bundle install ; esac ;; - bin|test|docs|man|dist) + # docs/* targets handled here via Walkfile inheritance + docs) + case $phase in + deps) echo docs/all ;; + esac ;; + + docs/all) + case $phase in + deps) echo docs/index.html ;; + esac ;; + + docs/index.html) + case $phase in + deps) echo man/walk.1.html ;; + exec) cp man/walk.1.html $target ;; + esac ;; + + bin|test|man|dist) case $phase in deps) echo $target/all ;; esac ;; diff --git a/docs/Walkfile b/docs/Walkfile deleted file mode 100755 index 23eed9a..0000000 --- a/docs/Walkfile +++ /dev/null @@ -1,15 +0,0 @@ -#!/bin/bash - -target=$2 - -case $target in - all) - case $1 in - deps) echo index.html ;; - esac ;; - index.html) - case $1 in - deps) echo ../man/walk.1.html ;; - exec) cp ../man/walk.1.html index.html ;; - esac ;; -esac diff --git a/docs/index.html b/docs/index.html index 05f0aac..1832c9b 100644 --- a/docs/index.html +++ b/docs/index.html @@ -143,24 +143,28 @@

TARGETS

relate to an actual file on disk, it's synonymous with .PHONY targets in make(1).

-

walk(1) delegates to an executable file called Walkfile within the same -directory as the target, to determine what dependencies the target has, and how -to execute it.

+

walk(1) delegates to an executable file called 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 (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 From 0877dd90dc178461c70aee56c5dd9a7a5863f9ac Mon Sep 17 00:00:00 2001 From: "Eric J. Holmes" Date: Fri, 6 Mar 2026 16:54:31 -0800 Subject: [PATCH 3/8] Document breaking changes in CHANGELOG Add detailed breaking changes section and migration guide for Walkfile inheritance feature. Co-Authored-By: Claude --- CHANGELOG.md | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 322d0eb..53b6f04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,33 @@ **Features** -* 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. The target name passed to the Walkfile (`$2`) is now the path relative to the Walkfile's directory. +* 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. + +**Breaking Changes** + +* **Target name (`$2`) format changed**: When a Walkfile is found in a parent directory, `$2` now contains the path relative to the Walkfile's directory (e.g., `subdir/foo.o` instead of `foo.o`). Walkfiles that assume `$2` is a simple filename may need adjustment: + ```bash + # Before: worked when $2 was "foo.o" + echo ${target}.c + + # After: use basename or adjust patterns + echo ${target%.o}.c # works for both "foo.o" and "subdir/foo.o" + ``` + +* **Working directory changed**: The Walkfile now runs from its own directory, not the target's directory. Commands using relative paths like `ls *.c` should be updated to use paths relative to the Walkfile. + +* **Unintended Walkfile discovery**: Directories that previously had no Walkfile (treating targets as static files) may now inherit a Walkfile from a parent directory. If the parent Walkfile doesn't handle the target, this will produce an error instead of a no-op. + +**Migration Guide** + +1. If your Walkfile uses `$2` directly as a filename, consider using `basename "$target"` or updating patterns to handle paths. + +2. If you rely on "no Walkfile means static file" behavior in a subdirectory, add an explicit case to the parent Walkfile: + ```bash + subdir/*) ;; # treat as static files + ``` + +3. Review any Walkfiles higher in your directory tree that might now be discovered unexpectedly. ## 0.3.3 (2017-09-20) From bd2e17d1f4435cb29b3d5a6521bfa5f6509154d7 Mon Sep 17 00:00:00 2001 From: "Eric J. Holmes" Date: Fri, 6 Mar 2026 17:13:43 -0800 Subject: [PATCH 4/8] Add Walkfile fallback with exit code 127 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 --- CHANGELOG.md | 8 +++ man/walk.1 | 10 ++++ man/walk.1.html | 10 ++++ man/walk.1.md | 9 +++ plan.go | 155 ++++++++++++++++++++++++++++++------------------ plan_test.go | 80 +++++++++++++++++++++++++ 6 files changed, 215 insertions(+), 57 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53b6f04..4cf9db3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ * 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 127): 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 127 ;; # delegate to parent + esac + ``` + **Breaking Changes** * **Target name (`$2`) format changed**: When a Walkfile is found in a parent directory, `$2` now contains the path relative to the Walkfile's directory (e.g., `subdir/foo.o` instead of `foo.o`). Walkfiles that assume `$2` is a simple filename may need adjustment: diff --git a/man/walk.1 b/man/walk.1 index dd493f5..8a4db11 100644 --- a/man/walk.1 +++ b/man/walk.1 @@ -46,6 +46,16 @@ The \fIphase\fR (\fBdeps\fR or \fBexec\fR)\. The target path relative to the Walkfile's directory (e\.g\. \fBhello\.o\fR or \fBsubdir/hello\.o\fR if the Walkfile is in a parent directory)\. .P It's up to the \fBWalkfile\fR to determine what dependencies the target has, and how to execute it\. +.P +If a Walkfile exits with code \fB127\fR, 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: +.IP "" 4 +.nf +case $target in + special) ;; # handle locally + *) exit 127 ;; # delegate to parent +esac +.fi +.IP "" 0 .SH "PHASES" walk(1) has two phases: .TP diff --git a/man/walk.1.html b/man/walk.1.html index 1832c9b..f8e6db2 100644 --- a/man/walk.1.html +++ b/man/walk.1.html @@ -170,6 +170,16 @@

WALKFILE

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

+

If a Walkfile exits with code 127, 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 127 ;; # delegate to parent
+esac
+
+

PHASES

walk(1) has two phases:

diff --git a/man/walk.1.md b/man/walk.1.md index bff3b01..67da57c 100644 --- a/man/walk.1.md +++ b/man/walk.1.md @@ -86,6 +86,15 @@ positional arguments: It's up to the `Walkfile` to determine what dependencies the target has, and how to execute it. +If a Walkfile exits with code `127`, 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 127 ;; # delegate to parent + esac + ## PHASES walk(1) has two phases: diff --git a/plan.go b/plan.go index 00047c0..ebe3eae 100644 --- a/plan.go +++ b/plan.go @@ -221,6 +221,10 @@ func (e *targetError) Error() string { return fmt.Sprintf("%s: %v", e.target.Name(), e.err) } +// ExitCodeFallback is the exit code that signals walk to try the next +// Walkfile in the inheritance chain. +const ExitCodeFallback = 127 + // target is a Target implementation, that represents a file on disk, which may // be built by a rule. type target struct { @@ -230,17 +234,14 @@ type target struct { // The absolute path to the file. path string - // path to the rulefile to use. This is determined by the RuleFile - // function. - rulefile string - - // the directory to use as the working directory when executing the - // build file. - dir string + // rulefiles is the list of candidate Walkfiles, ordered from most + // specific (closest to target) to least specific (furthest up the tree). + rulefiles []string - // targetName is the target name relative to the Walkfile's directory. - // This is passed as $2 to the Walkfile. - targetName string + // rulefileIdx is the index into rulefiles of the active Walkfile. + // This is set during Dependencies() when a Walkfile successfully handles + // the target (doesn't return ExitCodeFallback). + rulefileIdx int // The working directory. wd string @@ -251,26 +252,39 @@ type target struct { // newTarget initializes and returns a new target instance. func newTarget(wd, name string) *target { path := abs(wd, name) + rulefiles := RuleFiles(path) - rulefile := RuleFile(path) + return &target{ + name: name, + path: path, + rulefiles: rulefiles, + wd: wd, + } +} - var dir string - var targetName string - if rulefile != "" { - // Use the Walkfile's directory as the working directory - dir = filepath.Dir(rulefile) - // Compute target name relative to the Walkfile's directory - targetName, _ = filepath.Rel(dir, path) +// rulefile returns the currently active Walkfile path. +func (t *target) rulefile() string { + if len(t.rulefiles) == 0 { + return "" } + return t.rulefiles[t.rulefileIdx] +} - return &target{ - name: name, - path: path, - rulefile: rulefile, - dir: dir, - targetName: targetName, - wd: wd, +// dir returns the working directory for the active Walkfile. +func (t *target) dir() string { + if rf := t.rulefile(); rf != "" { + return filepath.Dir(rf) } + return "" +} + +// targetName returns the target name relative to the active Walkfile's directory. +func (t *target) targetName() string { + if dir := t.dir(); dir != "" { + rel, _ := filepath.Rel(dir, t.path) + return rel + } + return "" } // Name implements the Target interface. @@ -281,7 +295,7 @@ func (t *target) Name() string { // 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 == "" { + if t.rulefile() == "" { return nil } @@ -296,50 +310,66 @@ func (t *target) Exec(ctx context.Context) error { // out the newline delimited list of dependencies. func (t *target) Dependencies(ctx context.Context) ([]string, error) { // No .walk file, meaning it's a static dependency. - if t.rulefile == "" { + if len(t.rulefiles) == 0 { return nil, nil } - b := new(bytes.Buffer) - cmd, err := t.ruleCommand(ctx, PhaseDeps) - if err != nil { - return nil, err - } - cmd.Stdout = b - - if err := cmd.Run(); err != nil { - return nil, err - } + // Try each Walkfile in order until one handles the target + // (doesn't return ExitCodeFallback). + var lastErr error + for i := range t.rulefiles { + t.rulefileIdx = i - var deps []string - scanner := bufio.NewScanner(b) - for scanner.Scan() { - path := scanner.Text() - if path == "" { - continue + b := new(bytes.Buffer) + cmd, err := t.ruleCommand(ctx, PhaseDeps) + if err != nil { + return nil, err } + cmd.Stdout = b - // If the path is not already and absolute path, make it one. - if !filepath.IsAbs(path) { - path = filepath.Join(t.dir, path) + if err := cmd.Run(); err != nil { + // Check if this is a fallback signal + if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == ExitCodeFallback { + lastErr = err + continue // Try next Walkfile + } + return nil, err } - // Make all paths relative to the working directory. - path, err := filepath.Rel(t.wd, path) - if err != nil { - return deps, err + // This Walkfile handled it - parse dependencies + var deps []string + scanner := bufio.NewScanner(b) + for scanner.Scan() { + path := scanner.Text() + if path == "" { + continue + } + + // If the path is not already and absolute path, make it one. + if !filepath.IsAbs(path) { + path = filepath.Join(t.dir(), path) + } + + // Make all paths relative to the working directory. + path, err := filepath.Rel(t.wd, path) + if err != nil { + return deps, err + } + deps = append(deps, path) } - deps = append(deps, path) + + return deps, scanner.Err() } - return deps, scanner.Err() + // All Walkfiles returned fallback - return the last error + return nil, lastErr } func (t *target) ruleCommand(ctx context.Context, phase string) (*exec.Cmd, error) { - cmd := exec.CommandContext(ctx, t.rulefile, phase, t.targetName) + cmd := exec.CommandContext(ctx, t.rulefile(), phase, t.targetName()) cmd.Stdout = t.stdout cmd.Stderr = t.stderr - cmd.Dir = t.dir + cmd.Dir = t.dir() return cmd, nil } @@ -361,7 +391,7 @@ func (t *verboseTarget) Exec(ctx context.Context) error { if err != nil { line = fmt.Sprintf("%s\t%s", line, err) } - if t.rulefile != "" { + if t.rulefile() != "" { fmt.Fprintf(t.stdout, "%s\n", line) } if err != nil { @@ -374,12 +404,23 @@ func (t *verboseTarget) Exec(ctx context.Context) error { // the Rule to execute the given target. It walks up the directory tree from the // target's directory until it finds a Walkfile. func RuleFile(path string) string { + rulefiles := RuleFiles(path) + if len(rulefiles) == 0 { + return "" + } + return rulefiles[0] +} + +// RuleFiles returns all candidate Walkfiles for the given target path, ordered +// from most specific (closest to target) to least specific (furthest ancestor). +func RuleFiles(path string) []string { + var rulefiles []string dir := filepath.Dir(path) for { walkfile := filepath.Join(dir, Walkfile) if _, err := os.Stat(walkfile); err == nil { - return walkfile + rulefiles = append(rulefiles, walkfile) } parent := filepath.Dir(dir) @@ -389,7 +430,7 @@ func RuleFile(path string) string { dir = parent } - return "" + return rulefiles } // prefixWriter wraps an io.Writer to append a prefix to each line written. diff --git a/plan_test.go b/plan_test.go index 6018bc1..b486830 100644 --- a/plan_test.go +++ b/plan_test.go @@ -230,6 +230,86 @@ esac assert.NoError(t, err, "expected subdir/foo.built to exist") } +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 + tmpdir := t.TempDir() + + // 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 127 ;; +esac +`), 0755) + assert.NoError(t, err) + + // Create subdirectory + subdir := filepath.Join(tmpdir, "subdir") + err = os.Mkdir(subdir, 0755) + assert.NoError(t, err) + + // Local Walkfile: handles "special" only, exits 127 for unknown targets + localWalkfile := filepath.Join(subdir, "Walkfile") + err = os.WriteFile(localWalkfile, []byte(`#!/bin/bash +phase=$1 +target=$2 + +case $target in + special) + case $phase in + exec) touch "$target.from-local" ;; + esac ;; + *) exit 127 ;; # Signal: try parent Walkfile +esac +`), 0755) + assert.NoError(t, err) + + // Test 1: "special" should be handled by local Walkfile + b := new(bytes.Buffer) + plan := newPlan() + plan.NewTarget = NewTarget(TargetOptions{ + WorkingDir: tmpdir, + Stdout: b, + }) + + err = plan.Plan(ctx, "subdir/special") + assert.NoError(t, err) + err = plan.Exec(ctx, NewSemaphore(0)) + assert.NoError(t, err) + + _, err = os.Stat(filepath.Join(subdir, "special.from-local")) + assert.NoError(t, err, "expected special.from-local to exist") + + // Test 2: "foo.o" should fall back to parent Walkfile + plan = newPlan() + plan.NewTarget = NewTarget(TargetOptions{ + WorkingDir: tmpdir, + Stdout: b, + }) + + err = plan.Plan(ctx, "subdir/foo.o") + assert.NoError(t, err) + err = plan.Exec(ctx, NewSemaphore(0)) + assert.NoError(t, err) + + _, err = os.Stat(filepath.Join(tmpdir, "subdir/foo.o.from-parent")) + assert.NoError(t, err, "expected subdir/foo.o.from-parent to exist") +} + func TestRuleFile_LocalOverridesParent(t *testing.T) { // Create a temp directory structure: // tmpdir/ From fa56db006155d40379b9205107db078df913d9d4 Mon Sep 17 00:00:00 2001 From: "Eric J. Holmes" Date: Fri, 6 Mar 2026 17:18:02 -0800 Subject: [PATCH 5/8] Change delegate exit code from 127 to 200 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 --- CHANGELOG.md | 4 ++-- man/walk.1 | 4 ++-- man/walk.1.html | 4 ++-- man/walk.1.md | 4 ++-- plan.go | 10 +++++----- plan_test.go | 4 ++-- 6 files changed, 15 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cf9db3..2e06fcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,11 +6,11 @@ * 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 127): 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: +* 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 127 ;; # delegate to parent + *) exit 200 ;; # delegate to parent esac ``` diff --git a/man/walk.1 b/man/walk.1 index 8a4db11..0ae9052 100644 --- a/man/walk.1 +++ b/man/walk.1 @@ -47,12 +47,12 @@ The target path relative to the Walkfile's directory (e\.g\. \fBhello\.o\fR or \ .P It's up to the \fBWalkfile\fR to determine what dependencies the target has, and how to execute it\. .P -If a Walkfile exits with code \fB127\fR, 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: +If a Walkfile exits with code \fB200\fR, 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: .IP "" 4 .nf case $target in special) ;; # handle locally - *) exit 127 ;; # delegate to parent + *) exit 200 ;; # delegate to parent esac .fi .IP "" 0 diff --git a/man/walk.1.html b/man/walk.1.html index f8e6db2..d190725 100644 --- a/man/walk.1.html +++ b/man/walk.1.html @@ -170,13 +170,13 @@

WALKFILE

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

-

If a Walkfile exits with code 127, walk(1) will try the next Walkfile up the +

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 127 ;; # delegate to parent
+  *) exit 200 ;; # delegate to parent
 esac
 
diff --git a/man/walk.1.md b/man/walk.1.md index 67da57c..1e887bf 100644 --- a/man/walk.1.md +++ b/man/walk.1.md @@ -86,13 +86,13 @@ positional arguments: It's up to the `Walkfile` to determine what dependencies the target has, and how to execute it. -If a Walkfile exits with code `127`, walk(1) will try the next Walkfile up the +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 127 ;; # delegate to parent + *) exit 200 ;; # delegate to parent esac ## PHASES diff --git a/plan.go b/plan.go index ebe3eae..99add0b 100644 --- a/plan.go +++ b/plan.go @@ -221,9 +221,9 @@ func (e *targetError) Error() string { return fmt.Sprintf("%s: %v", e.target.Name(), e.err) } -// ExitCodeFallback is the exit code that signals walk to try the next +// ExitCodeDelegate is the exit code that signals walk to try the next // Walkfile in the inheritance chain. -const ExitCodeFallback = 127 +const ExitCodeDelegate = 200 // target is a Target implementation, that represents a file on disk, which may // be built by a rule. @@ -240,7 +240,7 @@ type target struct { // rulefileIdx is the index into rulefiles of the active Walkfile. // This is set during Dependencies() when a Walkfile successfully handles - // the target (doesn't return ExitCodeFallback). + // the target (doesn't return ExitCodeDelegate). rulefileIdx int // The working directory. @@ -315,7 +315,7 @@ func (t *target) Dependencies(ctx context.Context) ([]string, error) { } // Try each Walkfile in order until one handles the target - // (doesn't return ExitCodeFallback). + // (doesn't return ExitCodeDelegate). var lastErr error for i := range t.rulefiles { t.rulefileIdx = i @@ -329,7 +329,7 @@ func (t *target) Dependencies(ctx context.Context) ([]string, error) { if err := cmd.Run(); err != nil { // Check if this is a fallback signal - if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == ExitCodeFallback { + if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == ExitCodeDelegate { lastErr = err continue // Try next Walkfile } diff --git a/plan_test.go b/plan_test.go index b486830..455e0ed 100644 --- a/plan_test.go +++ b/plan_test.go @@ -252,7 +252,7 @@ case $target in case $phase in exec) touch "$target.from-parent" ;; esac ;; - *) exit 127 ;; + *) exit 200 ;; esac `), 0755) assert.NoError(t, err) @@ -273,7 +273,7 @@ case $target in case $phase in exec) touch "$target.from-local" ;; esac ;; - *) exit 127 ;; # Signal: try parent Walkfile + *) exit 200 ;; # Signal: try parent Walkfile esac `), 0755) assert.NoError(t, err) From d5d5dbd123fb36625a1b1621394eb28515f33252 Mon Sep 17 00:00:00 2001 From: "Eric J. Holmes" Date: Fri, 6 Mar 2026 17:21:10 -0800 Subject: [PATCH 6/8] Demonstrate inheritance by generalizing *.o rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- test/110-compile/Walkfile | 11 +++++------ test/Walkfile | 12 ++++++++++++ 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/test/110-compile/Walkfile b/test/110-compile/Walkfile index a5f3c5d..31434ec 100755 --- a/test/110-compile/Walkfile +++ b/test/110-compile/Walkfile @@ -22,15 +22,14 @@ case $target in exec) clean ;; esac ;; - hello.o) - case $phase in - deps) echo hello.c ;; - exec) exec gcc -Wall -o hello.o -c $($0 deps $target) ;; - esac ;; + test) ;; # phony target hello) case $phase in deps) echo hello.o ;; exec) exec gcc -Wall -o hello $($0 deps $target) ;; - esac + esac ;; + + # Delegate unknown targets (like *.o) to parent Walkfile + *) exit 200 ;; esac diff --git a/test/Walkfile b/test/Walkfile index d0b351e..60a710b 100755 --- a/test/Walkfile +++ b/test/Walkfile @@ -40,5 +40,17 @@ case $target in exec) exec go test -race ../... esac ;; + # Generic rule: compile any .c file to .o + # This is inherited by subdirectories via exit 200 + */*.o) + src=${target%.o}.c + case $phase in + deps) echo $src ;; + exec) exec gcc -Wall -o $target -c $src ;; + esac ;; + + # Static files - no build needed + */*.c|*/*.h) ;; + *) >&2 echo "No rule for target \"$target\"" && exit 1 ;; esac From 12ee1e5165b08684224f7de18bb0082ff6cd6231 Mon Sep 17 00:00:00 2001 From: "Eric J. Holmes" Date: Mon, 9 Mar 2026 08:46:31 -0700 Subject: [PATCH 7/8] Regenerate docs/index.html with delegation docs The exit code 200 delegation documentation was missing from the generated HTML docs. Co-Authored-By: Claude --- docs/index.html | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/index.html b/docs/index.html index 1832c9b..d190725 100644 --- a/docs/index.html +++ b/docs/index.html @@ -170,6 +170,16 @@

WALKFILE

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
+
+

PHASES

walk(1) has two phases:

From 8c60b9a806a223c6e29729541de4d7f63de22659 Mon Sep 17 00:00:00 2001 From: "Eric J. Holmes" Date: Mon, 9 Mar 2026 08:48:15 -0700 Subject: [PATCH 8/8] Fix stale references to exit code 127 Updated comments and docs that still mentioned 127 to say 200. Co-Authored-By: Claude --- CHANGELOG.md | 2 +- plan_test.go | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e06fcd..8f7d1ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ * 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: +* Walkfile fallback (exit code 200): A local Walkfile can delegate to a parent Walkfile by exiting with code 200. This enables composition where a local Walkfile handles specific targets while inheriting generic rules from a parent: ```bash case $target in special) ;; # handle locally diff --git a/plan_test.go b/plan_test.go index 455e0ed..76bc6dc 100644 --- a/plan_test.go +++ b/plan_test.go @@ -231,12 +231,12 @@ esac } func TestRuleFile_Fallback(t *testing.T) { - // Test that a local Walkfile can delegate to parent by exiting with code 127 + // Test that a local Walkfile can delegate to parent by exiting with code 200 // Structure: // tmpdir/ // Walkfile <- handles *.o generically // subdir/ - // Walkfile <- handles "special" only, exits 127 for others + // Walkfile <- handles "special" only, exits 200 for others // foo.o <- should fall back to parent's *.o rule // special <- handled by local Walkfile tmpdir := t.TempDir() @@ -262,7 +262,7 @@ esac err = os.Mkdir(subdir, 0755) assert.NoError(t, err) - // Local Walkfile: handles "special" only, exits 127 for unknown targets + // Local Walkfile: handles "special" only, exits 200 for unknown targets localWalkfile := filepath.Join(subdir, "Walkfile") err = os.WriteFile(localWalkfile, []byte(`#!/bin/bash phase=$1