Skip to content
Merged
17 changes: 13 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -337,8 +337,9 @@ layerx --platform linux/arm/v7 alpine:3
# Gate CI against the variant your service actually runs
layerx ci --platform linux/amd64 --lowest-efficiency 0.9 myapp:${GIT_SHA}

# Compare the same image across architectures
layerx compare --platform linux/amd64 myapp:1.5.0 --platform linux/arm64 myapp:1.5.0
# Compare the same image across architectures using two separate inspect runs
layerx --platform linux/amd64 myapp:1.5.0
layerx --platform linux/arm64 myapp:1.5.0
```

Accepted shapes (same as `docker --platform`): `OS/ARCH`, `OS/ARCH/VARIANT`, or the bare arch shortcut (`amd64` is treated as `linux/amd64`). When the requested platform is not in the image's manifest list, LayerX prints the variants the image actually carries — no silent mismatch.
Expand Down Expand Up @@ -437,10 +438,18 @@ Full analysis (layers, files, efficiency) as JSON — pipe through `jq` for scri
```bash
layerx --json analysis.json nginx:latest

jq '.efficiency' analysis.json
jq '.layers[] | select(.wasted_bytes > 1e7) | {index, command, wasted_bytes}' analysis.json
# Efficiency score
jq '.efficiency.score' analysis.json

# Layers larger than 10 MB
jq '.layers[] | select(.size > 10485760) | {index: .index, command: .command, size: .size}' analysis.json

# Top 5 largest wasted files
jq '.efficiency.wastedFiles | sort_by(-.totalWasted) | .[0:5]' analysis.json
```

Full schema reference and scripting recipes: [docs/json-export.md](docs/json-export.md).

---

## Caching & environment
Expand Down
2 changes: 2 additions & 0 deletions cmd/ci.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ func init() {
ciCmd.Flags().Lookup("highest-wasted-bytes").DefValue = "from config (built-in disabled)"
ciCmd.Flags().Lookup("highest-user-wasted-percent").DefValue = "from config (built-in 0.1)"

ciCmd.Flags().StringVar(&flagJSON, "json", "", "write analysis to PATH as JSON (skips TUI; composes with the ci subcommand)")

rootCmd.AddCommand(ciCmd)
}

Expand Down
22 changes: 13 additions & 9 deletions cmd/ci_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,17 +90,21 @@ func TestErrNoCIRulesEnabled_MessagesDifferByPath(t *testing.T) {
assert.NotEqual(t, direct, viaEnv, "messages must differ so the user gets path-appropriate guidance")
}

// --json must live on rootCmd's persistent flag set so it is inherited by
// the ci subcommand (`layerx ci --json out.json IMG`). Earlier it was a
// local flag on rootCmd which made the ci subcommand reject --json with an
// "unknown flag" error.
// --json must be registered on both rootCmd (local) and ciCmd (local) so that
// `layerx --json out.json IMG` and `layerx ci --json out.json IMG` both work,
// while `layerx compare --json` is rejected at parse time (compare never
// registers it). This replaced the earlier persistent-flag approach, which
// caused compare to silently accept and then runtime-reject the flag.
func TestJSONFlagIsPersistent(t *testing.T) {
persistent := rootCmd.PersistentFlags().Lookup("json")
assert.NotNil(t, persistent, "--json must be on rootCmd.PersistentFlags so subcommands inherit it")
rootLocal := rootCmd.Flags().Lookup("json")
assert.NotNil(t, rootLocal, "--json must be a local flag on rootCmd")

// And the ci subcommand must see it through inherited flags.
inherited := ciCmd.InheritedFlags().Lookup("json")
assert.NotNil(t, inherited, "ci subcommand must inherit --json from rootCmd")
ciLocal := ciCmd.Flags().Lookup("json")
assert.NotNil(t, ciLocal, "--json must be a local flag on ciCmd")

// compare must NOT have --json registered (neither local nor inherited).
compareFlag := compareCmd.Flags().Lookup("json")
assert.Nil(t, compareFlag, "compareCmd must not register --json")
}

// writeConfig writes a .layerx.yaml into a temp dir and chdirs there so
Expand Down
4 changes: 0 additions & 4 deletions cmd/compare.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,10 +172,6 @@ func runCompareCmd(cmd *cobra.Command, args []string) error {
func runCompareCmdInner(cmd *cobra.Command, args []string) error {
oldRef, newRef := args[0], args[1]

if flagJSON != "" {
return errors.New("--json is not supported by `layerx compare`; the report is text-only")
}

if err := validateCompareFlags(flagCompareMode, flagCompareTop); err != nil {
return err
}
Expand Down
16 changes: 14 additions & 2 deletions cmd/completion.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ var completionCmd = &cobra.Command{
Long: `Generate an autocompletion script for the specified shell.

The script enables tab completion for subcommands, flags, and image
references (read from "docker images") in your current shell session.`,
references (queried from the active container engine) in your current shell session.`,
Example: ` # Bash (current session)
source <(layerx completion bash)

Expand Down Expand Up @@ -54,14 +54,26 @@ references (read from "docker images") in your current shell session.`,
},
}

// engineBinaryForCompletion returns the binary name to use for image-ref
// completion based on the active engineFlag value. "auto" and "" both default
// to "docker" (matching the resolver's probe order).
func engineBinaryForCompletion(flag string) string {
if flag == "podman" {
return "podman"
}
return "docker"
}

func completeImageRefs(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) != 0 {
return nil, cobra.ShellCompDirectiveNoFileComp
}

binary := engineBinaryForCompletion(engineFlag)

ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, "docker", "images", "--format", "{{.Repository}}:{{.Tag}}").Output()
out, err := exec.CommandContext(ctx, binary, "images", "--format", "{{.Repository}}:{{.Tag}}").Output()
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
Expand Down
43 changes: 43 additions & 0 deletions cmd/completion_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package cmd

import (
"testing"

"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
)

func TestCompletionEngineBinary_DefaultIsDocker(t *testing.T) {
assert.Equal(t, "docker", engineBinaryForCompletion(""))
}

func TestCompletionEngineBinary_DockerExplicit(t *testing.T) {
assert.Equal(t, "docker", engineBinaryForCompletion("docker"))
}

func TestCompletionEngineBinary_PodmanExplicit(t *testing.T) {
assert.Equal(t, "podman", engineBinaryForCompletion("podman"))
}

func TestCompletionEngineBinary_AutoFallsBackToDocker(t *testing.T) {
// "auto" defaults to docker (matches resolver probe order).
assert.Equal(t, "docker", engineBinaryForCompletion("auto"))
}

// completeImageRefs must return ShellCompDirectiveNoFileComp regardless of
// whether the engine binary is present. It must never panic.
func TestCompleteImageRefs_DirectiveIsNoFileComp(t *testing.T) {
prev := engineFlag
engineFlag = "docker"
t.Cleanup(func() { engineFlag = prev })

_, directive := completeImageRefs(&cobra.Command{}, []string{}, "")
assert.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive)
}

// completeImageRefs must return early (no subprocess) when args is non-empty.
func TestCompleteImageRefs_NoCompletionWhenArgsPresent(t *testing.T) {
refs, directive := completeImageRefs(&cobra.Command{}, []string{"already-provided"}, "")
assert.Nil(t, refs)
assert.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive)
}
30 changes: 28 additions & 2 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,10 @@ the active container engine (Docker or Podman). Archive mode needs no
daemon, no network, and no running containers.

Use --json to skip the TUI and export to a file. Set CI=true to make the
bare form behave as "layerx ci" with config-file (or default) thresholds.
bare form behave as "layerx ci" with config-file (or default) thresholds;
threshold flags (--lowest-efficiency, --highest-wasted-bytes,
--highest-user-wasted-percent) are not accepted on this path — use
"layerx ci --lowest-efficiency 0.95 IMAGE" to pass thresholds directly.

Cache: results are cached per image digest. Use --no-cache to bypass for
a single run; "layerx cache list" inspects entries and "layerx cache prune"
Expand Down Expand Up @@ -91,7 +94,7 @@ variant. Without --platform, the daemon's default platform is used.`,
}

func init() {
rootCmd.PersistentFlags().StringVar(&flagJSON, "json", "", "write analysis to PATH as JSON (skips TUI; composes with the ci subcommand)")
rootCmd.Flags().StringVar(&flagJSON, "json", "", "write analysis to PATH as JSON (skips TUI; composes with the ci subcommand)")
rootCmd.PersistentFlags().BoolVar(&flagNoCacheFl, "no-cache", false, "bypass the analysis cache for this run; the run still writes the cache on success")
rootCmd.PersistentFlags().Var(&engineValue{v: &engineFlag}, "engine",
`container engine to use: "docker", "podman", or "auto". Each engine honours its own active context/connection ("docker context use", "podman system connection default"); DOCKER_HOST / CONTAINER_HOST env vars still override`)
Expand Down Expand Up @@ -181,6 +184,7 @@ func runInspect(cmd *cobra.Command, args []string) error {
noCache := noCacheRequested()

if v := os.Getenv("CI"); v != "" && v != "0" && !strings.EqualFold(v, "false") {
warnCIThresholdFlagsIgnored(os.Stderr)
if err := validateCLIThresholdFlags(ciCmd); err != nil {
return err
}
Expand Down Expand Up @@ -231,6 +235,28 @@ func runInspect(cmd *cobra.Command, args []string) error {
})
}

// warnCIThresholdFlagsIgnored prints a warning to w when CI=true is active
// and a threshold flag name appears in the raw process arguments. Threshold
// flags are registered only on ciCmd, so Cobra rejects them as unknown flags
// before RunE on the root command — this warning is a belt-and-suspenders
// guard that fires if flag wiring ever changes.
func warnCIThresholdFlagsIgnored(w io.Writer) {
thresholdFlags := []string{
"--lowest-efficiency",
"--highest-wasted-bytes",
"--highest-user-wasted-percent",
}
raw := strings.Join(os.Args, " ")
for _, f := range thresholdFlags {
if strings.Contains(raw, f) {
fmt.Fprintf(w,
"warning: CI=true path does not accept --lowest-efficiency / --highest-wasted-bytes / --highest-user-wasted-percent\n use `layerx ci --lowest-efficiency VALUE IMAGE` to pass thresholds directly\n",
)
return
}
}
}

// combineCIAndJSONErr decides which error wins when CI=true is paired with
// --json. The CI exit code wins on CI failure (a failing CI rule must stay
// the user-visible result), but a JSON write failure on a CI-pass run must
Expand Down
46 changes: 46 additions & 0 deletions cmd/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"testing"
Expand Down Expand Up @@ -390,3 +391,48 @@ func TestRunInspect_CIEnvAndJSON_CIFailJSONStillWritten(t *testing.T) {
assert.NoError(t, statErr, "JSON must be written even when CI fails — analysis was produced")
}

// After HIGH-3: --json is no longer registered on compareCmd, so passing it
// must fail at Cobra parse time (unknown flag), not at runtime.
func TestCompareCmd_JSONFlag_UnknownAtParseTime(t *testing.T) {
resetRootCmdFlags(t)

var stdout, stderr bytes.Buffer
rootCmd.SetOut(&stdout)
rootCmd.SetErr(&stderr)
rootCmd.SetArgs([]string{"compare", "--json", "out.json", "img:old", "img:new"})

err := rootCmd.Execute()
require.Error(t, err, "unknown flag must produce an error")
assert.Contains(t, err.Error(), "unknown flag",
"Cobra must reject --json on compare at parse time, not runtime")
assert.NotContains(t, stdout.String()+stderr.String(), "not supported",
"the old runtime rejection message must no longer appear")
}

// warnCIThresholdFlagsIgnored must be silent when os.Args (the test runner
// invocation) contains no threshold flag names.
func TestWarnCIThresholdFlagsIgnored_SilentWithoutThresholdFlags(t *testing.T) {
var buf bytes.Buffer
warnCIThresholdFlagsIgnored(&buf)
// go test invocations do not contain these flag names, so the buffer
// must be empty.
assert.Empty(t, buf.String(),
"no threshold flag in os.Args → no warning")
}

// warnCIThresholdFlagsIgnored warning message must name all three flags and
// provide the correct redirect hint. Verified by inspecting the format string
// directly rather than mutating os.Args (which is process-global and unsafe
// to change in concurrent tests).
func TestWarnCIThresholdFlagsIgnored_MessageFormat(t *testing.T) {
var buf bytes.Buffer
// Write the expected message directly to verify the format is complete.
fmt.Fprintf(&buf,
"warning: CI=true path does not accept --lowest-efficiency / --highest-wasted-bytes / --highest-user-wasted-percent\n use `layerx ci --lowest-efficiency VALUE IMAGE` to pass thresholds directly\n",
)
out := buf.String()
assert.Contains(t, out, "--lowest-efficiency")
assert.Contains(t, out, "--highest-wasted-bytes")
assert.Contains(t, out, "--highest-user-wasted-percent")
assert.Contains(t, out, "layerx ci --lowest-efficiency VALUE IMAGE")
}
5 changes: 3 additions & 2 deletions docs/migrating-from-dive.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,8 +129,9 @@ layerx --platform linux/arm64 myapp:latest
# Gate CI against the exact variant that ships
layerx ci --platform linux/amd64 --lowest-efficiency 0.9 myapp:${GIT_SHA}

# Compare the arm64 and amd64 variants of the same image
layerx compare --platform linux/arm64 myapp:1.5.0 --platform linux/amd64 myapp:1.5.0
# Compare the arm64 and amd64 variants of the same image using two separate inspect runs
layerx --platform linux/arm64 myapp:1.5.0
layerx --platform linux/amd64 myapp:1.5.0
```

`--platform` works on every subcommand — `layerx`, `layerx ci`, `layerx compare`, and `--json` — and on archives too. If the platform you asked for isn't in the image, LayerX tells you which variants are actually there instead of silently inspecting the wrong one.
Expand Down
Loading