Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
96ca98f
refactor(cli): Rework the help text and end-of-run output
misonijnik Aug 23, 2026
d69761f
refactor(cli): Rename the model flags to --passthrough-models and --j…
misonijnik Aug 23, 2026
d2e674f
style(cli): drop semicolons from help text, messages, and comments
misonijnik Aug 23, 2026
dbe650a
style(docs): drop the remaining semicolons in usage.md
misonijnik Aug 23, 2026
311bf5e
fix(cli): keep values from both spellings of renamed flags, quote she…
misonijnik Aug 24, 2026
134146d
docs(cli): rework the command help in simplified technical English
misonijnik Aug 24, 2026
f79367d
docs(cli): keep Go support out of this branch's help and docs
misonijnik Aug 25, 2026
a86bb2b
feat(cli): SARIF baseline comparison and suppression primitives
misonijnik Jul 23, 2026
c43bea9
feat(cli): triage engine, failure gate and summary subsections
misonijnik Jul 23, 2026
5f44843
feat(cli): triage command, and baseline/gate flags on scan and summary
misonijnik Jul 23, 2026
f62961f
feat(cli): rules.only and rules.exclude allow/deny lists in the config
misonijnik Jul 23, 2026
85ed37c
docs(cli): document triage, baselines, suppressions and rule lists
misonijnik Jul 23, 2026
3fe940d
docs: list triage in the docs README command table
misonijnik Jul 23, 2026
76ef096
refactor(cli): review cleanups — reuse matchers, single baseline load
misonijnik Jul 23, 2026
0603408
feat(cli): --exclude-rule-id flag as the counterpart of --rule-id
misonijnik Jul 24, 2026
6ff7743
refactor(cli): one rule-id grammar — rule selection reuses summary's …
misonijnik Jul 24, 2026
b5984bc
feat(cli): warn when a rule selection pattern matches no rule
misonijnik Jul 24, 2026
af6f0ea
feat(cli): resolve rule exclusion to the analyzer's --semgrep-rule-id…
misonijnik Jul 24, 2026
ba6ed85
refactor(cli): split baseline-state into a write switch and a display…
misonijnik Jul 28, 2026
9506b1d
fix(cli): accept comma-separated --error-on-severity values
misonijnik Jul 28, 2026
d9836ca
docs: add Baselines, suppressions & CI gating guide
misonijnik Jul 30, 2026
8d2b424
fix(cli): one fingerprint identity, honest baseline-state and Fixed c…
misonijnik Aug 5, 2026
c4edf21
docs: report on finding-fingerprint stability
misonijnik Aug 5, 2026
eb26437
feat(cli): sink-hash identity, and short names for the fingerprint keys
misonijnik Aug 7, 2026
b4144d9
feat(cli): default to the sink identity, and name what moved under it
misonijnik Aug 7, 2026
677cba9
docs: retract the claim that #336 fixed the fingerprint drift
misonijnik Aug 7, 2026
76bfc96
fix(cli): stop reporting "path changed", the signal behind it is noise
misonijnik Aug 8, 2026
35bccdd
Revert "fix(cli): stop reporting "path changed", the signal behind it…
misonijnik Aug 9, 2026
c0876aa
polish(cli): bring triage/baseline help up to the reworked help style
misonijnik Aug 23, 2026
abd405a
style(cli): drop semicolons from prose in the triage and baseline work
misonijnik Aug 23, 2026
93202b0
fix(cli): close the review findings on rule selection, identity, and …
misonijnik Aug 24, 2026
26e37df
fix(cli): satisfy errcheck on SaveReport's best-effort cleanup
misonijnik Aug 24, 2026
4154332
docs(cli): triage help in simplified technical English, with recipes
misonijnik Aug 24, 2026
173faf7
feat(cli): stop calling every absent finding fixed, name what remains…
misonijnik Aug 24, 2026
20bdf42
feat(cli): fix the finding identity to the sink hash, drop --fingerpr…
misonijnik Aug 24, 2026
74a9538
fix(cli): harden baseline and suppression handling
misonijnik Aug 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions cli/cmd/analyzer_inputs.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import (

func addDataflowApproximations(b *AnalyzerBuilder, paths []string, analyzerJarPath, projectModelDir string) {
for _, approxPath := range paths {
absApproxPath := log.AbsPathOrExit(approxPath, "dataflow-approximations")
absApproxPath := log.AbsPathOrExit(approxPath, "java-models")
compiledPath, err := compileApproximationsIfNeeded(absApproxPath, analyzerJarPath, projectModelDir)
if err != nil {
out.Fatalf("Approximation compilation failed: %s", err)
Expand All @@ -17,6 +17,6 @@ func addDataflowApproximations(b *AnalyzerBuilder, paths []string, analyzerJarPa

func addPassthroughApproximations(b *AnalyzerBuilder, paths []string) {
for _, passthrough := range paths {
b.AddPassthroughApproximations(log.AbsPathOrExit(passthrough, "passthrough-approximations"))
b.AddPassthroughApproximations(log.AbsPathOrExit(passthrough, "passthrough-models"))
}
}
10 changes: 10 additions & 0 deletions cli/cmd/command_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ type AnalyzerBuilder struct {
jarPath string
maxMemory string
ruleIDs []string
ruleIDExcludes []string
passthroughApproximations []string
dataflowApproximations []string
trackExternalMethods bool
Expand Down Expand Up @@ -146,6 +147,11 @@ func (a *AnalyzerBuilder) AddRuleID(ruleID string) *AnalyzerBuilder {
return a
}

func (a *AnalyzerBuilder) AddRuleIDExclude(ruleID string) *AnalyzerBuilder {
a.ruleIDExcludes = append(a.ruleIDExcludes, ruleID)
return a
}

func (a *AnalyzerBuilder) AddPassthroughApproximations(path string) *AnalyzerBuilder {
a.passthroughApproximations = append(a.passthroughApproximations, path)
return a
Expand Down Expand Up @@ -249,6 +255,10 @@ func (a *AnalyzerBuilder) BuildNativeCommand() []string {
flags = append(flags, "--semgrep-rule-id", ruleID)
}

for _, ruleID := range a.ruleIDExcludes {
flags = append(flags, "--semgrep-rule-id-exclude", ruleID)
}

for _, passthrough := range a.passthroughApproximations {
flags = append(flags, "--passthrough-approximations", passthrough)
}
Expand Down
30 changes: 30 additions & 0 deletions cli/cmd/command_builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cmd

import (
"reflect"
"strings"
"testing"
)

Expand Down Expand Up @@ -99,3 +100,32 @@ func TestAutobuilderBuildNativeCommandRoutesDependenciesToDependencyFlag(t *test
t.Fatalf("package com.example not passed as --pkg; command was %v", cmd)
}
}

func TestAnalyzerBuilderEmitsRuleIDIncludeAndExclude(t *testing.T) {
cmd := NewAnalyzerBuilder().
SetProject("p.yaml").
AddRuleID("a.yaml:keep").
AddRuleIDExclude("a.yaml:drop").
BuildNativeCommand()

joined := strings.Join(cmd, " ")
if !strings.Contains(joined, "--semgrep-rule-id a.yaml:keep") {
t.Errorf("missing inclusion flag: %s", joined)
}
if !strings.Contains(joined, "--semgrep-rule-id-exclude a.yaml:drop") {
t.Errorf("missing exclusion flag: %s", joined)
}
}

func TestAnalyzerBuilderExclusionOnlyEmitsNoInclusionFlags(t *testing.T) {
cmd := NewAnalyzerBuilder().
SetProject("p.yaml").
AddRuleIDExclude("a.yaml:drop").
BuildNativeCommand()

for i, arg := range cmd {
if arg == "--semgrep-rule-id" {
t.Errorf("unexpected inclusion flag at %d: %v", i, cmd)
}
}
}
39 changes: 26 additions & 13 deletions cli/cmd/compile.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,21 +32,33 @@ func currentCompileBuilder(projectPath string) *utils.OpentaintCommandBuilder {
// dockerCompileSuggestion builds the "try Docker-based compilation" fallback hint.
func dockerCompileSuggestion() output.Suggestion {
return output.Suggestion{
Description: dockerFallbackHintPrefix + "compilation:",
Description: "If the required Java is missing, set JAVA_HOME or compile in a container instead:",
Command: utils.BuildCompileCommandWithDocker(currentCompileBuilder(""), ProjectPath, OutputProjectModelPath),
}
}

// compileCmd represents the compile command
var compileCmd = &cobra.Command{
Use: "compile project",
Short: "Compile your Java or Kotlin project",
Use: "compile <project>",
Short: "Compile a project into a reusable project model",
Args: cobra.ExactArgs(1), // require exactly one argument
Long: `This command takes a required path to the project, automatically detects Java/Kotlin build system, modules and dependencies and compiles project model.
Long: `Compile a project into a project model that you can scan many times. OpenTaint finds the build system, collects the modules and dependencies, and builds the project.

Arguments:
project - Path to a project to compile (required)
`,
The project argument is the path to the project root. It is required. Use --output to set the project model directory. This directory must not exist before the command runs.

Later scans can use the model without a new build. This makes repeated scans fast.

Before your first compile, run "opentaint pull" one time. To scan the model, use "opentaint scan --project-model".`,
Example: ` # Compile the current directory into a project model
opentaint compile . -o ./model

# Make sure the inputs are correct, without a build
opentaint compile . -o ./model --dry-run

# Recipe: compile one time, then scan with different settings
opentaint compile ./my-app -o ./model
opentaint scan --project-model ./model --ruleset ./team-rules -o team.sarif
opentaint scan --project-model ./model --severity error -o errors.sarif`,
Annotations: map[string]string{"PrintConfig": "true"},
Run: func(cmd *cobra.Command, args []string) {
ProjectPath = args[0]
Expand All @@ -72,33 +84,34 @@ Arguments:
sb.Line()
}
sb.FieldNode("Project", absProjectRoot).
FieldNode("Output project model", absOutputProjectModelPath).
FieldNode("Project model", absOutputProjectModelPath).
FieldNode("Autobuilder", utils.ArtifactVersionWithPath(globals.ArtifactByKind("autobuilder"))).
Render()

if DryRunCompile {
out.Blank()
failOnInvalidInputs(func() error { return validation.ValidateCompileInputs(absProjectRoot, absOutputProjectModelPath) })
runDryRun("Compilation")
runDryRun("compilation")
return
}

autobuilderJarPath, err := ensureAutobuilderAvailable()
if err != nil {
out.Fatalf("Native compile preparation failed: %s", err)
failf("Native compile preparation failed: %s", err)
}

compileJavaRunner := newAutobuilderJavaRunner()
if _, err := compileJavaRunner.EnsureJava(); err != nil {
out.Fatalf("Failed to resolve Java for compilation: %s", err)
failf("Failed to resolve Java for compilation: %s", err)
}

if err := out.RunWithSpinner("Compiling project model", func() error {
return compile(absProjectRoot, absOutputProjectModelPath, autobuilderJarPath, compileJavaRunner)
}); err == nil {
out.Blank()
printCompileSummary(absOutputProjectModelPath)
suggest("To scan project run", utils.BuildScanCommandFromCompile(projectRoot, absOutputProjectModelPath))
out.Successf("Compilation completed.")
suggest("To scan the compiled project model, run:", utils.BuildScanCommandFromCompile(projectRoot, absOutputProjectModelPath))
} else {
out.InteractiveBlank()
failWith(1, fmt.Sprintf("Native compile has failed: %s", err), dockerCompileSuggestion())
Expand All @@ -109,7 +122,7 @@ Arguments:
func init() {
rootCmd.AddCommand(compileCmd)

compileCmd.Flags().StringVarP(&OutputProjectModelPath, "output", "o", "", `Path to the result project model`)
compileCmd.Flags().StringVarP(&OutputProjectModelPath, "output", "o", "", `Path to the project model directory to create (required, must not exist)`)
_ = compileCmd.MarkFlagRequired("output")
compileCmd.Flags().BoolVar(&DryRunCompile, "dry-run", false, "Validate inputs and show what would run without compiling")
compileCmd.Flags().StringVar(&CompileLogFile, "log-file", "", "Path to the log file (default: <cache-dir>/logs/<timestamp>.log)")
Expand Down
2 changes: 1 addition & 1 deletion cli/cmd/compile_approximations.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import (
// bundles approximation support sources (OpentaintNdUtil, ArgumentTypeContext).
const approxClassesJarPrefix = "opentaint-dataflow-approximations/"

// compileApproximationsIfNeeded checks whether a --dataflow-approximations directory
// compileApproximationsIfNeeded checks whether a --java-models directory
// contains .java source files. If so, it compiles them using javac (with the
// analyzer JAR + project dependencies on the classpath) and returns the path to
// the compiled .class output directory. If the directory already contains only
Expand Down
47 changes: 45 additions & 2 deletions cli/cmd/dry_run.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,56 @@
package cmd

import "fmt"
import (
"os"
"strings"
)

func failOnInvalidInputs(validate func() error) {
if err := validate(); err != nil {
out.Fatalf("Input validation failed: %s", err)
}
}

// runDryRun prints the standard dry-run tail: a status line naming the skipped
// action, then a suggestion to repeat the same invocation without --dry-run.
func runDryRun(skippedAction string) {
out.Print(fmt.Sprintf("Dry run mode. Inputs validated. %s skipped.", skippedAction))
out.Printf("Dry run complete. Inputs validated, %s skipped.", skippedAction)
suggest("To run for real, run:", rerunWithoutDryRun())
}

// rerunWithoutDryRun reconstructs the current invocation with the --dry-run
// flag removed, so the dry-run tail can suggest the real run verbatim. It works
// from os.Args, which keeps it correct for every command that shares this tail
// (scan, compile, project, test rule reachability).
func rerunWithoutDryRun() string {
args := []string{"opentaint"}
for _, arg := range os.Args[1:] {
if arg == "--dry-run" || strings.HasPrefix(arg, "--dry-run=") {
continue
}
args = append(args, shellQuote(arg))
}
return strings.Join(args, " ")
}

// shellQuote single-quotes an argument that would break when copy-pasted into
// a shell. Only arguments made of known-inert characters pass through
// unchanged, so globs, variables, and separators survive the round trip.
func shellQuote(arg string) string {
if arg != "" && !strings.ContainsFunc(arg, shellUnsafe) {
return arg
}
return "'" + strings.ReplaceAll(arg, "'", `'\''`) + "'"
}

// shellUnsafe reports whether a character can change the meaning of an
// unquoted shell word. The safe set mirrors Python's shlex.quote.
func shellUnsafe(r rune) bool {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
return false
case strings.ContainsRune("_@%+=:,./-", r):
return false
}
return true
}
45 changes: 45 additions & 0 deletions cli/cmd/exit_codes.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package cmd

import (
"fmt"

"github.com/seqra/opentaint/internal/analyzer"
)

// analyzerExitCodeRows renders the forwarded analyzer exit codes (252-255) as
// help rows. The row text is generated from analyzer.ExitMessage so the
// documented codes can never drift from the runtime failure messages.
func analyzerExitCodeRows() string {
rows := ""
for _, code := range []int{analyzer.ExitException, analyzer.ExitOOM, analyzer.ExitTimeout, analyzer.ExitConfigError} {
rows += fmt.Sprintf("\n %-3d %s", code, analyzer.ExitMessage(code))
}
return rows
}

// scanExitCodesHelp renders the exit-codes block for commands that forward
// analyzer exit codes but have no failure gate (test rule reachability).
func scanExitCodesHelp(completedLine string) string {
return `Exit codes:
0 ` + completedLine + `
1 General failure (configuration or infrastructure error)` + analyzerExitCodeRows()
}

// gateExitCodesHelp renders the exit-codes block for scan, which adds exit
// code 2 for the --error-on-findings gate on top of the forwarded analyzer
// codes.
func gateExitCodesHelp(completedLine string) string {
return `Exit codes:
0 ` + completedLine + `
1 General failure (configuration or infrastructure error)
2 Findings remain and --error-on-findings was set` + analyzerExitCodeRows()
}

// testExitCodesHelp renders the exit-codes block for the test-run commands,
// which add exit code 2 for sample failures.
func testExitCodesHelp(passedLine string) string {
return `Exit codes:
0 ` + passedLine + `
1 General failure (configuration or infrastructure error)
2 One or more tests failed (false negatives, false positives, or skipped samples)` + analyzerExitCodeRows()
}
42 changes: 42 additions & 0 deletions cli/cmd/flag_alias.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package cmd

import (
"strings"

"github.com/spf13/pflag"
)

// renamedStringArray backs a renamed flag and its deprecated alias. pflag's
// stringArrayValue replaces the bound slice on each flag's own first value, so
// two stock flags bound to one slice silently drop whatever the other spelling
// already collected. Appending unconditionally keeps the values of both
// spellings, in command-line order.
type renamedStringArray struct {
target *[]string
}

func (v renamedStringArray) String() string {
if len(*v.target) == 0 {
return ""
}
return "[" + strings.Join(*v.target, ",") + "]"
}

func (v renamedStringArray) Set(s string) error {
*v.target = append(*v.target, s)
return nil
}

func (v renamedStringArray) Type() string {
return "stringArray"
}

// addRenamedStringArrayFlag registers a flag under its new name and its
// deprecated old spelling, both accumulating into the same slice.
func addRenamedStringArrayFlag(fs *pflag.FlagSet, target *[]string, name, deprecated, usage string) {
fs.Var(renamedStringArray{target}, name, usage)
fs.Var(renamedStringArray{target}, deprecated, usage)
if err := fs.MarkDeprecated(deprecated, "use --"+name); err != nil {
panic(err)
}
}
56 changes: 56 additions & 0 deletions cli/cmd/flag_alias_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package cmd

import (
"reflect"
"testing"

"github.com/spf13/pflag"
)

func parseRenamed(t *testing.T, args []string) []string {
t.Helper()
var target []string
fs := pflag.NewFlagSet("test", pflag.ContinueOnError)
addRenamedStringArrayFlag(fs, &target, "passthrough-models", "passthrough-approximations", "usage")
if err := fs.Parse(args); err != nil {
t.Fatalf("parse %v: %v", args, err)
}
return target
}

func TestRenamedFlagAccumulatesAcrossBothSpellings(t *testing.T) {
cases := [][]string{
{"--passthrough-models", "a.yaml", "--passthrough-approximations", "b.yaml"},
{"--passthrough-approximations", "a.yaml", "--passthrough-models", "b.yaml"},
}
for _, args := range cases {
got := parseRenamed(t, args)
if len(got) != 2 {
t.Errorf("args %v: got %v, want both values kept", args, got)
}
}
}

func TestRenamedFlagKeepsRepeatsInOrder(t *testing.T) {
got := parseRenamed(t, []string{
"--passthrough-models", "a.yaml",
"--passthrough-models", "b.yaml",
"--passthrough-approximations", "c.yaml",
})
if want := []string{"a.yaml", "b.yaml", "c.yaml"}; !reflect.DeepEqual(got, want) {
t.Errorf("got %v, want %v", got, want)
}
}

func TestRenamedFlagAliasIsDeprecatedAndHidden(t *testing.T) {
var target []string
fs := pflag.NewFlagSet("test", pflag.ContinueOnError)
addRenamedStringArrayFlag(fs, &target, "passthrough-models", "passthrough-approximations", "usage")
alias := fs.Lookup("passthrough-approximations")
if alias == nil || alias.Deprecated == "" {
t.Fatal("alias must be registered and marked deprecated")
}
if fs.Lookup("passthrough-models").Deprecated != "" {
t.Error("the new spelling must not be deprecated")
}
}
Loading
Loading