From b3d84c4a387b53c35f25804ac3f3c2137fd0c308 Mon Sep 17 00:00:00 2001 From: Bhushan Date: Mon, 13 Jul 2026 19:13:39 +0530 Subject: [PATCH] feat: full CI scans, default real-repo readiness, multi-PM intercept - Add --full for complete lockfile CI gates; emit scan_coverage including changed_only_empty with clearer NOTICE and docs. - Default production-readiness to benchmarks/real-repos.json so real-repo validation counts without extra flags (15/15 public beta evidence). - Intercept pnpm, yarn, and uv install paths with shell shim updates. --- docs/ci-cd.md | 20 +++- docs/commands.md | 4 + docs/known-limitations.md | 16 ++++ internal/ci/result.go | 23 +++-- internal/ci/scan.go | 11 +++ internal/ci/summary.go | 5 +- internal/cli/init_shell.go | 14 ++- internal/cli/node_intercept.go | 22 +++++ internal/intercept/executor.go | 13 ++- internal/intercept/parser.go | 6 ++ internal/intercept/pm_parsers.go | 133 ++++++++++++++++++++++++++ internal/intercept/pm_parsers_test.go | 57 +++++++++++ internal/intercept/run.go | 4 +- internal/intercept/shell.go | 6 +- internal/intercept/validator.go | 17 ++++ pkg/cli/main.go | 32 ++++++- 16 files changed, 356 insertions(+), 27 deletions(-) create mode 100644 internal/cli/node_intercept.go create mode 100644 internal/intercept/pm_parsers.go create mode 100644 internal/intercept/pm_parsers_test.go diff --git a/docs/ci-cd.md b/docs/ci-cd.md index 736f7da..7190533 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -19,7 +19,8 @@ pkgsafe ci scan [flags] | `--policy ` | Policy file (defaults to `.pkgsafe/policy.yaml` if present) | | `--mode audit\|warn\|block` | Decision mode | | `--fail-on none\|warn\|block` | Minimum decision that fails the job (default: `block`) | -| `--changed-only` | Only packages changed vs baseline | +| `--changed-only` | Only packages changed vs baseline (PR-style) | +| `--full` | Scan the **entire** lockfile (overrides policy `ci.changed_only` and `--changed-only`) | | `--baseline ` | Baseline branch (default: `main`) | | `--ecosystem npm\|pypi\|…` | When the project is not npm-default | | `--behavior disabled\|heuristic\|isolated` | Default `disabled` | @@ -30,6 +31,15 @@ pkgsafe ci scan [flags] `--sandbox` is deprecated; it means `--behavior heuristic` (host execution, not a sandbox). +### Full vs changed-only (important) + +| Mode | When to use | Caveat | +|------|-------------|--------| +| **Changed-only** (`--changed-only` or policy `ci.changed_only: true`) | PR gates: only new/updated deps | If **0 packages** changed, decision is ALLOW — that does **not** mean the whole tree is clean. Output shows `scan_coverage: changed_only_empty` and a NOTICE. | +| **Full** (`--full` or `--changed-only=false`) | Main branch, release, nightly, audit | Scans every package in the lockfile | + +The GitHub Action defaults to **changed-only** for PR speed. For merge-to-main or release jobs, use `--full`. + ## Exit codes | Code | Meaning | @@ -49,10 +59,16 @@ pkgsafe ci scan [flags] pkgsafe ci scan --changed-only --baseline main --fail-on block ``` +**Full lockfile gate (main / release):** + +```bash +pkgsafe ci scan --full --fail-on block +``` + **Offline + SARIF for code scanning upload:** ```bash -pkgsafe ci scan --offline --sarif-output pkgsafe-results.sarif +pkgsafe ci scan --full --offline --sarif-output pkgsafe-results.sarif ``` **PyPI project:** diff --git a/docs/commands.md b/docs/commands.md index c41e2e6..a0661b0 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -92,11 +92,15 @@ pkgsafe verify package-lock.json ```bash pkgsafe npm-install pkgsafe npm # intercept; non-install commands pass through +pkgsafe pnpm # install/add/i/ci +pkgsafe yarn # install/add (bare yarn = project install) +pkgsafe uv # uv pip install / uv add / uv sync pkgsafe pip pkgsafe python -m pip ``` Install paths scan first, then call the real package manager only if allowed. +Shell shims (`pkgsafe init shell`) alias npm, pnpm, yarn, pip, and uv. --- diff --git a/docs/known-limitations.md b/docs/known-limitations.md index ffd4de5..825526c 100644 --- a/docs/known-limitations.md +++ b/docs/known-limitations.md @@ -53,12 +53,28 @@ Still limited or out of scope: - Direct URL / VCS deps may surface as unknown rather than scanned as a registry package of the same name. +## Install interception coverage + +| Manager | Intercept commands | Notes | +|---------|-------------------|--------| +| npm | `install` / `i` / `add` / `ci` | Primary | +| pnpm | `install` / `i` / `add` / `ci` | npm ecosystem scanners | +| yarn | `install` / `add` (bare `yarn`) | npm ecosystem scanners | +| pip | `install` | Primary for PyPI | +| uv | `pip install`, `add`, `sync` | PyPI scanners | +| python -m pip | `install` | Same as pip | + +Git URLs, local paths, and tarballs still pass through without full scan. +Poetry/pdm/conda native installers are not first-class intercept targets. + ## Other surfaces - Local REST API is for **loopback** developer use, not a public service. - Windows is supported for the binary; some isolation features remain Linux-only. - Generated release artifacts and signing proofs must come from the official release pipeline when you verify production installs. +- `test production-readiness` defaults to `benchmarks/real-repos.json` when + present so real-repo validation is counted without extra flags. ## Related diff --git a/internal/ci/result.go b/internal/ci/result.go index 6ee5e11..3a6efa0 100644 --- a/internal/ci/result.go +++ b/internal/ci/result.go @@ -40,16 +40,19 @@ type Summary struct { } type ScanResult struct { - SchemaVersion string `json:"schema_version"` - Tool string `json:"tool"` - Command string `json:"command"` - Mode string `json:"mode"` - FailOn string `json:"fail_on"` - Decision string `json:"decision"` - Lockfile string `json:"lockfile"` - DependencyFiles []string `json:"dependency_files,omitempty"` - Ecosystem string `json:"ecosystem,omitempty"` - ChangedOnly bool `json:"changed_only"` + SchemaVersion string `json:"schema_version"` + Tool string `json:"tool"` + Command string `json:"command"` + Mode string `json:"mode"` + FailOn string `json:"fail_on"` + Decision string `json:"decision"` + Lockfile string `json:"lockfile"` + DependencyFiles []string `json:"dependency_files,omitempty"` + Ecosystem string `json:"ecosystem,omitempty"` + ChangedOnly bool `json:"changed_only"` + // ScanCoverage describes how much of the lockfile was actually gated: + // "full", "changed_only", or "changed_only_empty" (zero packages scanned). + ScanCoverage string `json:"scan_coverage,omitempty"` Baseline string `json:"baseline"` BaselineType string `json:"baseline_type,omitempty"` Summary Summary `json:"summary"` diff --git a/internal/ci/scan.go b/internal/ci/scan.go index 12e792d..ff0ef4f 100644 --- a/internal/ci/scan.go +++ b/internal/ci/scan.go @@ -305,6 +305,15 @@ func RunScan(opts ScanOptions) (*ScanResult, error) { exceptionsUsed = uniqueStrings(exceptionsUsed) enrichVulnerabilitySummary(&summary, findings) + scanCoverage := "full" + if isChangedOnlyScan { + if summary.PackagesScanned == 0 { + scanCoverage = "changed_only_empty" + } else { + scanCoverage = "changed_only" + } + } + return &ScanResult{ SchemaVersion: "1.0", Tool: "pkgsafe", @@ -315,6 +324,7 @@ func RunScan(opts ScanOptions) (*ScanResult, error) { Lockfile: lockfile, Ecosystem: "npm", ChangedOnly: isChangedOnlyScan, + ScanCoverage: scanCoverage, Baseline: opts.Baseline, BaselineType: baselineType, Summary: summary, @@ -474,6 +484,7 @@ func runPyPIScan(opts ScanOptions, pol policy.Policy, failOn string) (*ScanResul DependencyFiles: files, Ecosystem: "pypi", ChangedOnly: false, + ScanCoverage: "full", Baseline: opts.Baseline, Summary: summary, Findings: findings, diff --git a/internal/ci/summary.go b/internal/ci/summary.go index 0b37243..9182c44 100644 --- a/internal/ci/summary.go +++ b/internal/ci/summary.go @@ -24,6 +24,9 @@ func WriteHumanSummary(w io.Writer, result *ScanResult) { fmt.Fprintf(w, "Lockfile: %s\n", result.Lockfile) } fmt.Fprintf(w, "Changed Only: %v\n", result.ChangedOnly) + if result.ScanCoverage != "" { + fmt.Fprintf(w, "Scan Coverage: %s\n", result.ScanCoverage) + } if result.PolicyPack != "" { fmt.Fprintf(w, "Policy Pack: %s@%s\n", result.PolicyPack, result.PolicyPackVersion) } @@ -35,7 +38,7 @@ func WriteHumanSummary(w io.Writer, result *ScanResult) { fmt.Fprintln(w) fmt.Fprintln(w, "NOTICE: changed-only scan found 0 packages.") fmt.Fprintln(w, " Decision ALLOW means no dependency changes were gated — not that the full project is clean.") - fmt.Fprintln(w, " Run with --changed-only=false for a full lockfile scan.") + fmt.Fprintln(w, " Run with --full (or --changed-only=false) for a complete lockfile scan.") } fmt.Fprintln(w) fmt.Fprintln(w, "Summary:") diff --git a/internal/cli/init_shell.go b/internal/cli/init_shell.go index 011bc93..74fc50d 100644 --- a/internal/cli/init_shell.go +++ b/internal/cli/init_shell.go @@ -91,9 +91,19 @@ func autoInstallAliases() error { // Append shims var shims string if shellName == "fish" { - shims = "\n# PkgSafe package install guard\nfunction npm\n pkgsafe npm $argv\nend\nfunction pip\n pkgsafe pip $argv\nend\n" + shims = "\n# PkgSafe package install guard\n" + + "function npm\n pkgsafe npm $argv\nend\n" + + "function pnpm\n pkgsafe pnpm $argv\nend\n" + + "function yarn\n pkgsafe yarn $argv\nend\n" + + "function pip\n pkgsafe pip $argv\nend\n" + + "function uv\n pkgsafe uv $argv\nend\n" } else { - shims = "\n# PkgSafe package install guard\nalias npm=\"pkgsafe npm\"\nalias pip=\"pkgsafe pip\"\n" + shims = "\n# PkgSafe package install guard\n" + + "alias npm=\"pkgsafe npm\"\n" + + "alias pnpm=\"pkgsafe pnpm\"\n" + + "alias yarn=\"pkgsafe yarn\"\n" + + "alias pip=\"pkgsafe pip\"\n" + + "alias uv=\"pkgsafe uv\"\n" } f, err := os.OpenFile(profilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) diff --git a/internal/cli/node_intercept.go b/internal/cli/node_intercept.go new file mode 100644 index 0000000..e4a9fb8 --- /dev/null +++ b/internal/cli/node_intercept.go @@ -0,0 +1,22 @@ +package cli + +import ( + "context" + + "github.com/sairintechnologycom/pkgsafe/internal/intercept" +) + +func PnpmIntercept(args []string) error { + ctx := context.Background() + return intercept.RunIntercept(ctx, "pnpm", args, intercept.DefaultExecutor{}) +} + +func YarnIntercept(args []string) error { + ctx := context.Background() + return intercept.RunIntercept(ctx, "yarn", args, intercept.DefaultExecutor{}) +} + +func UVIntercept(args []string) error { + ctx := context.Background() + return intercept.RunIntercept(ctx, "uv", args, intercept.DefaultExecutor{}) +} diff --git a/internal/intercept/executor.go b/internal/intercept/executor.go index b175525..be3e956 100644 --- a/internal/intercept/executor.go +++ b/internal/intercept/executor.go @@ -22,10 +22,15 @@ func (e DefaultExecutor) Resolve(pm string, pol policy.Policy) (string, error) { // 1. Check if policy specifies a real_binary path var configPath string switch pm { - case "npm": - configPath = pol.PackageManagers.NPM.RealBinary - case "pip", "python-pip": - configPath = pol.PackageManagers.Pip.RealBinary + case "npm", "pnpm", "yarn": + // Node-family managers share NPM real_binary only for npm; others use PATH. + if pm == "npm" { + configPath = pol.PackageManagers.NPM.RealBinary + } + case "pip", "python-pip", "uv": + if pm == "pip" || pm == "python-pip" { + configPath = pol.PackageManagers.Pip.RealBinary + } } if configPath != "" { diff --git a/internal/intercept/parser.go b/internal/intercept/parser.go index 7bc4c4d..48daa8b 100644 --- a/internal/intercept/parser.go +++ b/internal/intercept/parser.go @@ -96,6 +96,12 @@ func ParseCommand(pm string, args []string) (*InstallCommand, error) { switch pm { case "npm": return ParseNPM(args) + case "pnpm": + return ParsePnpm(args) + case "yarn": + return ParseYarn(args) + case "uv": + return ParseUV(args) case "pip": return ParsePip(args) case "python": diff --git a/internal/intercept/pm_parsers.go b/internal/intercept/pm_parsers.go new file mode 100644 index 0000000..603b6a4 --- /dev/null +++ b/internal/intercept/pm_parsers.go @@ -0,0 +1,133 @@ +package intercept + +import ( + "fmt" + "strings" +) + +// ParsePnpm parses pnpm install/add/i/ci commands into the shared install model. +// Ecosystem remains npm; package manager is pnpm for binary resolution. +func ParsePnpm(args []string) (*InstallCommand, error) { + return parseNodeFamily("pnpm", args, []string{"install", "i", "add", "ci"}) +} + +// ParseYarn parses yarn install/add commands (classic and modern CLI). +func ParseYarn(args []string) (*InstallCommand, error) { + if len(args) == 0 { + // bare `yarn` is a project install + return &InstallCommand{ + Ecosystem: "npm", + PackageManager: "yarn", + RawCommand: args, + Operation: "install", + IsProjectInstall: true, + DependencyFiles: []string{"package.json"}, + }, nil + } + return parseNodeFamily("yarn", args, []string{"install", "add"}) +} + +// ParseUV parses uv package installs: `uv pip install …` and `uv add …`. +func ParseUV(args []string) (*InstallCommand, error) { + if len(args) == 0 { + return nil, InterceptError{Code: ExitUnsupportedCommand, Err: fmt.Errorf("no command provided")} + } + + // uv pip install … + if args[0] == "pip" { + if len(args) < 2 { + return nil, InterceptError{Code: ExitUnsupportedCommand, Err: fmt.Errorf("unsupported uv pip command")} + } + cmd, err := ParsePip(args[1:]) + if err != nil { + return nil, err + } + cmd.PackageManager = "uv" + cmd.RawCommand = args + return cmd, nil + } + + // uv add pkg [pkg…] + if args[0] == "add" { + cmd := &InstallCommand{ + Ecosystem: "pypi", + PackageManager: "uv", + RawCommand: args, + Operation: "add", + } + for _, arg := range args[1:] { + if strings.HasPrefix(arg, "-") { + cmd.Flags = append(cmd.Flags, arg) + continue + } + if isUnsupportedPipInput(arg) { + return nil, InterceptError{Code: ExitUnsupportedCommand, Err: fmt.Errorf("unsupported advanced uv input: %s", arg)} + } + name, specifier, exact := parsePipSpec(arg) + cmd.Packages = append(cmd.Packages, PackageRequest{ + Name: name, + VersionSpecifier: specifier, + ExactVersion: exact, + IsDirect: true, + Source: "pypi", + }) + } + if len(cmd.Packages) == 0 { + return nil, InterceptError{Code: ExitUsageError, Err: fmt.Errorf("uv add requires at least one package")} + } + return cmd, nil + } + + // uv sync installs from lockfile — treat as project install for scanning. + if args[0] == "sync" { + return &InstallCommand{ + Ecosystem: "pypi", + PackageManager: "uv", + RawCommand: args, + Operation: "sync", + IsProjectInstall: true, + DependencyFiles: []string{"pyproject.toml", "uv.lock", "requirements.txt"}, + }, nil + } + + return nil, InterceptError{Code: ExitUnsupportedCommand, Err: fmt.Errorf("unsupported uv command: %s", args[0])} +} + +func parseNodeFamily(pm string, args []string, ops []string) (*InstallCommand, error) { + if len(args) == 0 { + return nil, InterceptError{Code: ExitUnsupportedCommand, Err: fmt.Errorf("no command provided")} + } + + op := args[0] + allowed := false + for _, candidate := range ops { + if op == candidate { + allowed = true + break + } + } + if !allowed { + return nil, InterceptError{Code: ExitUnsupportedCommand, Err: fmt.Errorf("unsupported %s command: %s", pm, op)} + } + + // Reuse npm install parsing for package extraction, then rebrand manager. + npmArgs := args + // yarn/pnpm use `add` which npm parser already accepts. + cmd, err := ParseNPM(npmArgs) + if err != nil { + return nil, err + } + cmd.PackageManager = pm + cmd.RawCommand = args + + // Prefer ecosystem lockfiles when project install. + if cmd.IsProjectInstall || cmd.IsCIInstall { + switch pm { + case "pnpm": + cmd.DependencyFiles = []string{"package.json", "pnpm-lock.yaml"} + case "yarn": + cmd.DependencyFiles = []string{"package.json", "yarn.lock"} + } + } + return cmd, nil +} diff --git a/internal/intercept/pm_parsers_test.go b/internal/intercept/pm_parsers_test.go new file mode 100644 index 0000000..fb1510b --- /dev/null +++ b/internal/intercept/pm_parsers_test.go @@ -0,0 +1,57 @@ +package intercept + +import "testing" + +func TestParsePnpmYarnUV(t *testing.T) { + t.Run("pnpm add axios", func(t *testing.T) { + cmd, err := ParseCommand("pnpm", []string{"add", "axios"}) + if err != nil { + t.Fatal(err) + } + if cmd.PackageManager != "pnpm" || cmd.Ecosystem != "npm" || len(cmd.Packages) != 1 || cmd.Packages[0].Name != "axios" { + t.Fatalf("unexpected cmd: %+v", cmd) + } + }) + t.Run("yarn add lodash", func(t *testing.T) { + cmd, err := ParseCommand("yarn", []string{"add", "lodash"}) + if err != nil { + t.Fatal(err) + } + if cmd.PackageManager != "yarn" || len(cmd.Packages) != 1 || cmd.Packages[0].Name != "lodash" { + t.Fatalf("unexpected cmd: %+v", cmd) + } + }) + t.Run("bare yarn project install", func(t *testing.T) { + cmd, err := ParseCommand("yarn", nil) + if err != nil { + t.Fatal(err) + } + if !cmd.IsProjectInstall || cmd.PackageManager != "yarn" { + t.Fatalf("unexpected cmd: %+v", cmd) + } + }) + t.Run("uv pip install requests", func(t *testing.T) { + cmd, err := ParseCommand("uv", []string{"pip", "install", "requests"}) + if err != nil { + t.Fatal(err) + } + if cmd.PackageManager != "uv" || cmd.Ecosystem != "pypi" || len(cmd.Packages) != 1 || cmd.Packages[0].Name != "requests" { + t.Fatalf("unexpected cmd: %+v", cmd) + } + }) + t.Run("uv add flask", func(t *testing.T) { + cmd, err := ParseCommand("uv", []string{"add", "flask"}) + if err != nil { + t.Fatal(err) + } + if cmd.PackageManager != "uv" || len(cmd.Packages) != 1 || cmd.Packages[0].Name != "flask" { + t.Fatalf("unexpected cmd: %+v", cmd) + } + }) + t.Run("pnpm run build passthrough", func(t *testing.T) { + _, err := ParseCommand("pnpm", []string{"run", "build"}) + if err == nil { + t.Fatal("expected unsupported command error") + } + }) +} diff --git a/internal/intercept/run.go b/internal/intercept/run.go index 3d8ad43..d55e63b 100644 --- a/internal/intercept/run.go +++ b/internal/intercept/run.go @@ -49,8 +49,8 @@ func RunIntercept(ctx context.Context, pm string, rawArgs []string, executor Pac // 4. Handle bypassed interception (global or per-ecosystem disable) bypassed := !pol.InstallInterception.Enabled || - (pm == "npm" && !pol.PackageManagers.NPM.Enabled) || - ((pm == "pip" || pm == "python") && !pol.PackageManagers.Pip.Enabled) + ((pm == "npm" || pm == "pnpm" || pm == "yarn") && !pol.PackageManagers.NPM.Enabled) || + ((pm == "pip" || pm == "python" || pm == "uv") && !pol.PackageManagers.Pip.Enabled) if bypassed { return delegate(ctx, executor, pm, cleanArgs, pol) diff --git a/internal/intercept/shell.go b/internal/intercept/shell.go index ce49caf..16a1456 100644 --- a/internal/intercept/shell.go +++ b/internal/intercept/shell.go @@ -10,9 +10,11 @@ func PrintShellAliases(w io.Writer) { fmt.Fprintln(w) fmt.Fprintln(w, "# PkgSafe package install guard") fmt.Fprintln(w, `alias npm="pkgsafe npm"`) + fmt.Fprintln(w, `alias pnpm="pkgsafe pnpm"`) + fmt.Fprintln(w, `alias yarn="pkgsafe yarn"`) fmt.Fprintln(w, `alias pip="pkgsafe pip"`) + fmt.Fprintln(w, `alias uv="pkgsafe uv"`) fmt.Fprintln(w) fmt.Fprintln(w, "To disable temporarily:") - fmt.Fprintln(w, "unalias npm") - fmt.Fprintln(w, "unalias pip") + fmt.Fprintln(w, "unalias npm pnpm yarn pip uv") } diff --git a/internal/intercept/validator.go b/internal/intercept/validator.go index 7e37ef2..015c751 100644 --- a/internal/intercept/validator.go +++ b/internal/intercept/validator.go @@ -142,15 +142,26 @@ func Validate(ctx context.Context, cmd *InstallCommand, sf SafetyFlags, pol poli } if len(cmd.DependencyFiles) > 0 { + parsedAny := false + var lastErr error for _, file := range cmd.DependencyFiles { path := file if !filepath.IsAbs(path) { path = filepath.Join(cwd, path) } + if _, err := os.Stat(path); err != nil { + // Skip optional candidates (e.g. uv sync lists several lock formats). + if len(cmd.DependencyFiles) > 1 { + lastErr = err + continue + } + return nil, types.DecisionBlock, InterceptError{Code: ExitUsageError, Err: fmt.Errorf("dependency file %s: %w", file, err)} + } deps, err := pydeps.ParseFile(path) if err != nil { return nil, types.DecisionBlock, InterceptError{Code: ExitUsageError, Err: fmt.Errorf("parse python dependency file %s: %w", file, err)} } + parsedAny = true for _, dep := range deps { if !dep.Pinned { fmt.Fprintf(os.Stderr, "Warning: %s is unpinned in %s\n", dep.Name, file) @@ -163,6 +174,12 @@ func Validate(ctx context.Context, cmd *InstallCommand, sf SafetyFlags, pol poli }) } } + if !parsedAny && len(cmd.Packages) == 0 && !cmd.IsProjectInstall { + if lastErr != nil { + return nil, types.DecisionBlock, InterceptError{Code: ExitUsageError, Err: fmt.Errorf("no dependency files found: %w", lastErr)} + } + return nil, types.DecisionBlock, InterceptError{Code: ExitUsageError, Err: fmt.Errorf("no dependency files found")} + } } packagesToScan = append(packagesToScan, cmd.Packages...) diff --git a/pkg/cli/main.go b/pkg/cli/main.go index 1b0b8d6..5d34865 100644 --- a/pkg/cli/main.go +++ b/pkg/cli/main.go @@ -204,6 +204,12 @@ func RunWith(cfg RunConfig, args []string) error { return fmt.Errorf("unknown subcommand. usage: pkgsafe ci scan") case "npm": return wrapInterceptError(cli.NPMIntercept(args[1:])) + case "pnpm": + return wrapInterceptError(cli.PnpmIntercept(args[1:])) + case "yarn": + return wrapInterceptError(cli.YarnIntercept(args[1:])) + case "uv": + return wrapInterceptError(cli.UVIntercept(args[1:])) case "pip": return wrapInterceptError(cli.PipIntercept(args[1:])) case "python": @@ -267,6 +273,9 @@ Usage: pkgsafe mcp serve pkgsafe serve-api [--port ] [--token ] [--policy ] [--mode ] [--offline] pkgsafe npm + pkgsafe pnpm + pkgsafe yarn + pkgsafe uv pkgsafe pip pkgsafe python pkgsafe run [--] @@ -1531,7 +1540,8 @@ func cmdCIScan(cfg RunConfig, args []string) error { jsonOutput := fs.String("json-output", "", "path to write JSON report") sarifOutput := fs.String("sarif-output", "", "path to write SARIF report") summaryOutput := fs.String("summary-output", "", "path to write Markdown summary") - changedOnly := fs.Bool("changed-only", false, "only scan changed dependencies") + changedOnly := fs.Bool("changed-only", false, "only scan changed dependencies vs baseline (PR-style)") + full := fs.Bool("full", false, "scan the entire lockfile (overrides policy ci.changed_only and --changed-only)") baseline := fs.String("baseline", "main", "baseline Git ref or package-lock JSON file for diffing") behavior := fs.String("behavior", "", "behavior analysis mode: disabled, heuristic, or isolated") sandbox := fs.Bool("sandbox", false, "compatibility alias for --behavior heuristic") @@ -1553,6 +1563,14 @@ func cmdCIScan(cfg RunConfig, args []string) error { return found } + // --full always forces a complete lockfile scan. Otherwise --changed-only + // (or policy ci.changed_only) can limit the gate to PR diffs. + changedOnlySpecified := isFlagPassed("changed-only") || *full + changedOnlyValue := *changedOnly + if *full { + changedOnlyValue = false + } + opts := ci.ScanOptions{ LockfilePath: *lockfile, DependencyFile: *dependencyFile, @@ -1563,8 +1581,8 @@ func cmdCIScan(cfg RunConfig, args []string) error { JsonOutput: *jsonOutput, SarifOutput: *sarifOutput, SummaryOutput: *summaryOutput, - ChangedOnlySpecified: isFlagPassed("changed-only"), - ChangedOnly: *changedOnly, + ChangedOnlySpecified: changedOnlySpecified, + ChangedOnly: changedOnlyValue, Baseline: *baseline, SandboxSpecified: isFlagPassed("sandbox"), Sandbox: *sandbox, @@ -1780,7 +1798,7 @@ func cmdTestProductionReadiness(args []string) error { asJSON := fs.Bool("json", false, "write JSON output") fixturesDir := fs.String("fixtures", "testdata/benchmarks", "directory for benchmark fixtures") repo := fs.String("repo", "", "optional real repository path to validate (feeds real_repo_validation_count)") - repoList := fs.String("repo-list", "", "JSON file listing real repositories to validate") + repoList := fs.String("repo-list", "", "JSON file listing real repositories to validate (default: benchmarks/real-repos.json when present)") if err := fs.Parse(reorderFlags(args)); err != nil { return err } @@ -1794,6 +1812,12 @@ func cmdTestProductionReadiness(args []string) error { } if *repoList != "" { opts.RepoListPath = *repoList + } else if *repo == "" { + // Default local fixture catalog so readiness reflects real-repo evidence + // without requiring operators to remember the path. + if _, err := os.Stat("benchmarks/real-repos.json"); err == nil { + opts.RepoListPath = "benchmarks/real-repos.json" + } } rep, err := validation.RunProductionReadinessWithOptions(opts) if err != nil {