← back to Cli Printing Press
fix(cli): walk parent dirs for research.json in live-check (#1057)
9a07f2872aa5c0bf61b21f7ed0263e928ec4f23e · 2026-05-11 06:51:49 -0700 · Trevin Chow
RunLiveCheck previously defaulted ResearchDir to CLIDir, so when invoked
from the standard pipeline layout (research.json at <runRoot>/research.json,
CLI built under <runRoot>/working/<api>-pp-cli) the file lookup failed
and scorecard's live-check returned unable=true with features=nil. The
Phase 4.85 output-review sub-skill treats that as SKIP, which silenced
the reviewer on every non-OpenAPI run.
When ResearchDir is empty, walk up to 3 parent levels from CLIDir
looking for a research.json sibling before falling back to CLIDir so the
existing "no research.json" error message remains intact when nothing is
found. The explicit ResearchDir override path is unchanged.
Fixes #885
Files touched
M internal/pipeline/live_check.goM internal/pipeline/live_check_test.go
Diff
commit 9a07f2872aa5c0bf61b21f7ed0263e928ec4f23e
Author: Trevin Chow <trevin@trevinchow.com>
Date: Mon May 11 06:51:49 2026 -0700
fix(cli): walk parent dirs for research.json in live-check (#1057)
RunLiveCheck previously defaulted ResearchDir to CLIDir, so when invoked
from the standard pipeline layout (research.json at <runRoot>/research.json,
CLI built under <runRoot>/working/<api>-pp-cli) the file lookup failed
and scorecard's live-check returned unable=true with features=nil. The
Phase 4.85 output-review sub-skill treats that as SKIP, which silenced
the reviewer on every non-OpenAPI run.
When ResearchDir is empty, walk up to 3 parent levels from CLIDir
looking for a research.json sibling before falling back to CLIDir so the
existing "no research.json" error message remains intact when nothing is
found. The explicit ResearchDir override path is unchanged.
Fixes #885
---
internal/pipeline/live_check.go | 44 ++++++++++++++++++++++--
internal/pipeline/live_check_test.go | 65 ++++++++++++++++++++++++++++++++++++
2 files changed, 106 insertions(+), 3 deletions(-)
diff --git a/internal/pipeline/live_check.go b/internal/pipeline/live_check.go
index 3bb7a5d2..5418987b 100644
--- a/internal/pipeline/live_check.go
+++ b/internal/pipeline/live_check.go
@@ -111,8 +111,11 @@ type LiveCheckOptions struct {
// instead of CLIDir. Use this when invoking live-check from a working
// directory the run state owns (where research.json lives next to the
// run's manuscripts) and the printed CLI hasn't been promoted to its
- // final library location. Leave blank to keep the historical behavior
- // of looking for research.json under CLIDir.
+ // final library location. When blank the live check looks under CLIDir
+ // and then walks up a few parent levels (see findResearchDir) so the
+ // standard pipeline layout — research.json at the run-dir level, CLI
+ // under <runRoot>/working/<api>-pp-cli — works without an explicit
+ // override.
ResearchDir string
// BinaryName, when non-empty, names the executable to run. Leave blank
// to let RunLiveCheck derive it from CLIDir (tries `<base>-pp-cli`,
@@ -142,7 +145,7 @@ func RunLiveCheck(opts LiveCheckOptions) *LiveCheckResult {
researchDir := opts.ResearchDir
if researchDir == "" {
- researchDir = opts.CLIDir
+ researchDir = findResearchDir(opts.CLIDir)
}
research, err := LoadResearch(researchDir)
if err != nil {
@@ -203,6 +206,41 @@ func RunLiveCheck(opts LiveCheckOptions) *LiveCheckResult {
return out
}
+// researchParentWalkDepth bounds how far above CLIDir the live check looks
+// for research.json. The standard pipeline lays out
+// <runRoot>/working/<api>-pp-cli, putting research.json two levels above
+// CLIDir; three is a small margin for layouts that add a wrapper directory
+// without inviting scans that could pick up unrelated research.json files
+// far above the working tree.
+const researchParentWalkDepth = 3
+
+// findResearchDir returns a directory containing research.json that the
+// live check can hand to LoadResearch. It first checks cliDir itself, then
+// walks up the parent chain up to researchParentWalkDepth levels. If no
+// research.json is found, cliDir is returned so the caller's error message
+// stays "no research.json: ... <cliDir>/research.json".
+//
+// The walk handles the canonical non-OpenAPI layout where research.json
+// sits at the run-dir level while the printed CLI lives under
+// <runRoot>/working/<api>-pp-cli.
+func findResearchDir(cliDir string) string {
+ if cliDir == "" {
+ return cliDir
+ }
+ dir := cliDir
+ for steps := 0; steps <= researchParentWalkDepth; steps++ {
+ if _, err := os.Stat(filepath.Join(dir, "research.json")); err == nil {
+ return dir
+ }
+ parent := filepath.Dir(dir)
+ if parent == dir {
+ break
+ }
+ dir = parent
+ }
+ return cliDir
+}
+
// resolveBinaryPath returns the absolute path to the CLI binary. When name
// is non-empty it's used verbatim; otherwise RunLiveCheck tries the common
// `<base>-pp-cli` naming convention and falls back to `<base>`.
diff --git a/internal/pipeline/live_check_test.go b/internal/pipeline/live_check_test.go
index 089df6eb..081dc2c4 100644
--- a/internal/pipeline/live_check_test.go
+++ b/internal/pipeline/live_check_test.go
@@ -83,6 +83,71 @@ func TestLiveCheck_ResearchDirOverride(t *testing.T) {
require.NotContains(t, r2.Reason, "no research.json", "should have read research.json from ResearchDir")
}
+// TestLiveCheck_FindsResearchInParentDir verifies that when ResearchDir is
+// empty and CLIDir has no research.json, the live check walks up the parent
+// chain to locate it. This is the standard pipeline layout where the printed
+// CLI lives under <runRoot>/working/<api>-pp-cli and research.json sits at
+// <runRoot>/research.json — two levels above the CLI dir. Without this walk
+// the Phase 4.85 output-review sub-skill silently SKIPs every non-OpenAPI
+// run.
+func TestLiveCheck_FindsResearchInParentDir(t *testing.T) {
+ runRoot := t.TempDir()
+ workingDir := filepath.Join(runRoot, "working")
+ cliDir := filepath.Join(workingDir, "demo-pp-cli")
+ require.NoError(t, os.MkdirAll(cliDir, 0o755))
+ writeStubBinary(t, cliDir, "bin", `exit 0`)
+ writeTestResearchJSON(t, runRoot, []NovelFeature{
+ {Name: "Feature A", Command: "foo", Description: "no example"},
+ })
+
+ // CLIDir is two levels under the dir holding research.json. The live
+ // check should walk up, locate it, and surface the next failure gate
+ // (no Example command) rather than reporting the research.json miss.
+ result := RunLiveCheck(LiveCheckOptions{CLIDir: cliDir, BinaryName: "bin", Timeout: time.Second})
+ require.True(t, result.Unable)
+ require.Contains(t, result.Reason, "Example", "should have located research.json via parent walk and reached the Example-command gate")
+ require.NotContains(t, result.Reason, "no research.json")
+}
+
+// TestLiveCheck_ParentWalkStopsAtBound pins the exact depth boundary so
+// off-by-one drift in researchParentWalkDepth or the loop bound surfaces
+// as a test failure. The walk should include cliDir and the next
+// researchParentWalkDepth (3) parents — research.json at depth 3 is found;
+// depth 4 is not.
+func TestLiveCheck_ParentWalkStopsAtBound(t *testing.T) {
+ t.Run("at bound is found", func(t *testing.T) {
+ root := t.TempDir()
+ atBound := filepath.Join(root, "a", "b", "cli")
+ require.NoError(t, os.MkdirAll(atBound, 0o755))
+ writeStubBinary(t, atBound, "bin", `exit 0`)
+ writeTestResearchJSON(t, root, []NovelFeature{
+ {Name: "Feature A", Command: "foo", Description: "no example"},
+ })
+
+ result := RunLiveCheck(LiveCheckOptions{CLIDir: atBound, BinaryName: "bin", Timeout: time.Second})
+ require.True(t, result.Unable)
+ require.Contains(t, result.Reason, "Example", "research.json at depth 3 should be reachable")
+ require.NotContains(t, result.Reason, "no research.json")
+ })
+
+ t.Run("one past bound is not found", func(t *testing.T) {
+ root := t.TempDir()
+ // Place a sentinel research.json above the test temp tree at the
+ // fake root so any stray host-filesystem research.json above
+ // t.TempDir() can't be picked up by the walk. The walk should
+ // stop before reaching it — that's what this assertion proves.
+ pastBound := filepath.Join(root, "a", "b", "c", "cli")
+ require.NoError(t, os.MkdirAll(pastBound, 0o755))
+ writeTestResearchJSON(t, root, []NovelFeature{
+ {Name: "Feature A", Command: "foo", Description: "no example"},
+ })
+
+ result := RunLiveCheck(LiveCheckOptions{CLIDir: pastBound, BinaryName: "bin", Timeout: time.Second})
+ require.True(t, result.Unable)
+ require.Contains(t, result.Reason, "research.json", "research.json at depth 4 should be out of reach")
+ })
+}
+
// TestLiveCheck_UnableWhenNoExamples verifies the check skips when research
// exists but no novel feature has an Example command.
func TestLiveCheck_UnableWhenNoExamples(t *testing.T) {
← d2770e16 fix(cli): honor auth.prefix on bearer_token specs (#1054)
·
back to Cli Printing Press
·
chore(main): release 4.3.0 (#898) 76db1cc4 →