[object Object]

← 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

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 →