2026-07-03 06:01:34 +00:00
package cli
import (
2026-07-04 00:59:32 +00:00
"context"
2026-07-03 17:01:24 +00:00
"encoding/json"
"flag"
2026-07-03 06:01:34 +00:00
"fmt"
"io"
2026-07-03 17:01:24 +00:00
"os"
2026-07-04 01:11:57 +00:00
"path"
"path/filepath"
2026-07-03 17:01:24 +00:00
"sort"
"strings"
2026-07-04 00:59:32 +00:00
"time"
2026-07-03 17:01:24 +00:00
2026-07-04 00:59:32 +00:00
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
2026-07-03 17:01:24 +00:00
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
2026-07-04 01:11:57 +00:00
"gitea.maximumdirect.net/eric/notarius/internal/core/diagnostics"
2026-07-04 00:59:32 +00:00
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
2026-07-03 17:01:24 +00:00
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
2026-07-03 06:01:34 +00:00
)
2026-07-03 17:01:24 +00:00
const defaultConfigPath = "/usr/local/etc/notarius/config.yml"
2026-07-04 01:11:57 +00:00
const defaultOutputRoot = "./notarius-output"
2026-07-03 17:01:24 +00:00
const usage = ` Usage :
notarius help
2026-07-05 17:51:36 +00:00
notarius run < pipeline - id > -- input path / to / source . json [ -- config path / to / config . yml ] [ -- only lane - a , lane - b ] [ -- session - id id ] [ -- reference selector = path ] [ -- without - reference selector ]
2026-07-03 17:01:24 +00:00
notarius config validate -- config path / to / config . yml [ -- pipeline pipeline - id ] [ -- only lane - a , lane - b ]
notarius pipelines list -- config path / to / config . yml [ -- json ]
`
type Options struct {
2026-07-04 00:59:32 +00:00
Catalog pipeline . ModuleCatalog
Registries pipeline . Registries
LLMClientFactory LLMClientFactory
LookupEnv func ( string ) ( string , bool )
Now func ( ) time . Time
2026-07-03 17:01:24 +00:00
}
2026-07-03 06:01:34 +00:00
2026-07-04 00:59:32 +00:00
type LLMClientFactory func ( ctx context . Context , cfg config . Config , profileID string ) ( contracts . StructuredLLMClient , [ ] artifacts . LLMProfileManifest , error )
2026-07-03 06:01:34 +00:00
// Run executes the command-line interface and returns a process exit code.
func Run ( args [ ] string , stdout , stderr io . Writer ) int {
2026-07-03 17:01:24 +00:00
return RunWithOptions ( args , stdout , stderr , Options { } )
}
func RunWithOptions ( args [ ] string , stdout , stderr io . Writer , opts Options ) int {
opts = normalizeOptions ( opts )
2026-07-03 06:01:34 +00:00
if len ( args ) == 0 {
writeUsage ( stdout )
return 0
}
switch args [ 0 ] {
case "help" , "--help" , "-h" :
writeUsage ( stdout )
return 0
2026-07-03 17:01:24 +00:00
case "config" :
return runConfig ( args [ 1 : ] , stdout , stderr , opts )
case "pipelines" :
return runPipelines ( args [ 1 : ] , stdout , stderr , opts )
2026-07-04 01:06:04 +00:00
case "run" :
return runPipelineCommand ( args [ 1 : ] , stdout , stderr , opts )
2026-07-03 06:01:34 +00:00
default :
fmt . Fprintf ( stderr , "notarius: unknown command %q\n" , args [ 0 ] )
writeUsage ( stderr )
return 2
}
}
func writeUsage ( w io . Writer ) {
fmt . Fprint ( w , usage )
}
2026-07-03 17:01:24 +00:00
func normalizeOptions ( opts Options ) Options {
if opts . LookupEnv == nil {
opts . LookupEnv = os . LookupEnv
}
2026-07-04 00:59:32 +00:00
if opts . Now == nil {
opts . Now = time . Now
}
if opts . LLMClientFactory == nil {
opts . LLMClientFactory = productionLLMClientFactory
}
2026-07-03 17:01:24 +00:00
return opts
}
2026-07-04 01:06:04 +00:00
func runPipelineCommand ( args [ ] string , stdout , stderr io . Writer , opts Options ) int {
fs := flag . NewFlagSet ( "run" , flag . ContinueOnError )
fs . SetOutput ( io . Discard )
configPath := fs . String ( "config" , "" , "config file path" )
inputPath := fs . String ( "input" , "" , "source input file path" )
onlyRaw := fs . String ( "only" , "" , "comma-separated artifact lanes" )
outputDir := fs . String ( "output-dir" , "" , "output directory" )
diagnosticsDir := fs . String ( "diagnostics-dir" , "" , "diagnostics directory" )
llmProfile := fs . String ( "llm-profile" , "" , "LLM profile override" )
2026-07-05 17:51:36 +00:00
sessionID := sessionIDFlag { }
2026-07-05 14:27:23 +00:00
referenceFlags := stringListFlag { }
withoutReferenceFlags := stringListFlag { }
2026-07-05 17:51:36 +00:00
fs . Var ( & sessionID , "session-id" , "prompt session identifier" )
2026-07-07 19:10:55 +00:00
fs . Var ( & referenceFlags , "reference" , "reference binding, as slot=path, chunk.slot=path, merge.slot=path, lane.slot=path, lane.extract.slot=path, lane.merge.slot=path, or lane.normalize.slot=path" )
2026-07-05 16:31:23 +00:00
fs . Var ( & withoutReferenceFlags , "without-reference" , "unbind a reference, using the same selector forms as --reference" )
2026-07-05 17:51:36 +00:00
if err := validateRunFlagValues ( args ) ; err != nil {
fmt . Fprintf ( stderr , "notarius: %v\n" , err )
return 2
}
2026-07-04 01:06:04 +00:00
if err := fs . Parse ( reorderRunArgs ( args ) ) ; err != nil {
fmt . Fprintf ( stderr , "notarius: %v\n" , err )
return 2
}
if fs . NArg ( ) == 0 {
fmt . Fprintln ( stderr , "notarius: run requires a pipeline ID" )
return 2
}
if fs . NArg ( ) > 1 {
fmt . Fprintf ( stderr , "notarius: unexpected argument %q\n" , fs . Arg ( 1 ) )
return 2
}
pipelineID := strings . TrimSpace ( fs . Arg ( 0 ) )
if pipelineID == "" {
fmt . Fprintln ( stderr , "notarius: run requires a pipeline ID" )
return 2
}
if strings . TrimSpace ( * inputPath ) == "" {
fmt . Fprintln ( stderr , "notarius: run requires --input" )
return 2
}
2026-07-05 17:51:36 +00:00
if sessionID . set && strings . TrimSpace ( sessionID . value ) == "" {
fmt . Fprintln ( stderr , "notarius: --session-id must not be empty" )
return 2
}
2026-07-04 01:06:04 +00:00
only , err := parseOnly ( * onlyRaw )
if err != nil {
fmt . Fprintf ( stderr , "notarius: %v\n" , err )
return 2
}
2026-07-05 14:27:23 +00:00
referenceRequests , err := parseReferenceFlags ( referenceFlags )
if err != nil {
fmt . Fprintf ( stderr , "notarius: %v\n" , err )
return 2
}
referenceUnbindRequests , err := parseReferenceUnbindFlags ( withoutReferenceFlags )
if err != nil {
fmt . Fprintf ( stderr , "notarius: %v\n" , err )
return 2
}
2026-07-04 01:06:04 +00:00
2026-07-04 01:11:57 +00:00
cfg , loadedConfigPath , err := loadConfig ( * configPath , opts )
2026-07-04 01:06:04 +00:00
if err != nil {
fmt . Fprintf ( stderr , "notarius: %v\n" , err )
return 1
}
if dir := strings . TrimSpace ( * diagnosticsDir ) ; dir != "" {
cfg . Diagnostics . WorkDir = dir
}
2026-07-04 01:11:57 +00:00
startedAt := opts . Now ( ) . UTC ( )
runDir , err := diagnostics . NewRunDirectory ( cfg . Diagnostics . WorkDir , cfg . Diagnostics . Retention )
2026-07-04 01:06:04 +00:00
if err != nil {
fmt . Fprintf ( stderr , "notarius: %v\n" , err )
return 1
}
2026-07-04 01:11:57 +00:00
invocation := diagnostics . InvocationMetadata {
Operation : "run" ,
PipelineID : pipelineID ,
InputPath : strings . TrimSpace ( * inputPath ) ,
ConfigPath : loadedConfigPath ,
ConfigSource : configSource ( * configPath ) ,
OnlyLanes : append ( [ ] string ( nil ) , only ... ) ,
RunID : runDir . RunID ( ) ,
StartedAt : startedAt ,
}
if err := runDir . WriteInvocationMetadata ( invocation ) ; err != nil {
return failPipelineCommand ( stderr , runDir , cfg . Diagnostics . Retention , fmt . Errorf ( "write diagnostics invocation metadata: %w" , err ) )
}
catalog , err := effectiveCatalog ( opts )
if err != nil {
return failPipelineCommand ( stderr , runDir , cfg . Diagnostics . Retention , err )
}
2026-07-05 14:27:23 +00:00
referenceOverrides , referenceUnbinds , err := resolveCLIReferenceRequests ( cfg , pipelineID , only , catalog , referenceRequests , referenceUnbindRequests )
if err != nil {
return failPipelineCommand ( stderr , runDir , cfg . Diagnostics . Retention , err )
}
2026-07-04 01:06:04 +00:00
effective , err := cfg . Resolve ( config . ResolveInput {
PipelineID : pipelineID ,
Only : only ,
Catalog : catalog ,
LLMProfileOverride : * llmProfile ,
2026-07-05 14:27:23 +00:00
ReferenceOverrides : referenceOverrides ,
ReferenceUnbinds : referenceUnbinds ,
2026-07-04 01:06:04 +00:00
} )
if err != nil {
2026-07-04 01:11:57 +00:00
return failPipelineCommand ( stderr , runDir , cfg . Diagnostics . Retention , err )
}
2026-07-05 18:03:23 +00:00
profileIDs := effectiveLLMProfileIDs ( effective . ResolvedPipeline )
if err := validateExplicitScriptoriumProfiles ( context . Background ( ) , effective . Config , profileIDs ) ; err != nil {
return failPipelineCommand ( stderr , runDir , cfg . Diagnostics . Retention , err )
}
2026-07-05 14:32:35 +00:00
workingDir , err := os . Getwd ( )
if err != nil {
return failPipelineCommand ( stderr , runDir , cfg . Diagnostics . Retention , fmt . Errorf ( "resolve working directory: %w" , err ) )
}
materialized , referenceWarnings , err := pipeline . MaterializeReferences ( effective . ResolvedPipeline , catalog , pipeline . ReferenceMaterializationOptions {
ConfigPath : loadedConfigPath ,
WorkingDir : workingDir ,
} )
if err != nil {
return failPipelineCommand ( stderr , runDir , cfg . Diagnostics . Retention , err )
}
effective . ResolvedPipeline = materialized
2026-07-04 01:11:57 +00:00
invocation . PipelineDigest = effective . ResolvedPipeline . Digest
if err := runDir . WriteInvocationMetadata ( invocation ) ; err != nil {
return failPipelineCommand ( stderr , runDir , cfg . Diagnostics . Retention , fmt . Errorf ( "write diagnostics invocation metadata: %w" , err ) )
}
if err := runDir . WriteRedactedEffectiveConfig ( effective ) ; err != nil {
return failPipelineCommand ( stderr , runDir , cfg . Diagnostics . Retention , fmt . Errorf ( "write diagnostics effective config: %w" , err ) )
}
if err := runDir . WriteResolvedPipeline ( effective . ResolvedPipeline ) ; err != nil {
return failPipelineCommand ( stderr , runDir , cfg . Diagnostics . Retention , fmt . Errorf ( "write diagnostics resolved pipeline: %w" , err ) )
2026-07-04 01:06:04 +00:00
}
2026-07-05 14:44:44 +00:00
if err := runDir . WriteResolvedReferences ( pipeline . ReferenceProvenance ( effective . ResolvedPipeline ) ) ; err != nil {
return failPipelineCommand ( stderr , runDir , cfg . Diagnostics . Retention , fmt . Errorf ( "write diagnostics resolved references: %w" , err ) )
}
2026-07-04 01:06:04 +00:00
rawInput , err := os . ReadFile ( strings . TrimSpace ( * inputPath ) )
if err != nil {
2026-07-04 01:11:57 +00:00
return failPipelineCommand ( stderr , runDir , cfg . Diagnostics . Retention , fmt . Errorf ( "read input %q: %w" , strings . TrimSpace ( * inputPath ) , err ) )
2026-07-04 01:06:04 +00:00
}
registries , err := effectiveRegistries ( opts )
if err != nil {
2026-07-04 01:11:57 +00:00
return failPipelineCommand ( stderr , runDir , cfg . Diagnostics . Retention , err )
2026-07-04 01:06:04 +00:00
}
ctx := context . Background ( )
2026-07-05 18:03:23 +00:00
factoryProfileID := ""
if len ( profileIDs ) == 1 {
factoryProfileID = profileIDs [ 0 ]
}
llmClient , llmProfiles , err := opts . LLMClientFactory ( ctx , effective . Config , factoryProfileID )
2026-07-04 01:06:04 +00:00
if err != nil {
2026-07-05 18:03:23 +00:00
return failPipelineCommand ( stderr , runDir , cfg . Diagnostics . Retention , fmt . Errorf ( "create LLM client for profile %q: %w" , factoryProfileID , err ) )
2026-07-04 01:06:04 +00:00
}
output , err := pipeline . New ( registries ) . Run ( ctx , pipeline . RunInput {
Pipeline : effective . ResolvedPipeline ,
Path : strings . TrimSpace ( * inputPath ) ,
RawInput : rawInput ,
LLMClient : llmClient ,
2026-07-05 17:51:36 +00:00
SessionID : strings . TrimSpace ( sessionID . value ) ,
2026-07-04 01:11:57 +00:00
RunID : runDir . RunID ( ) ,
StartedAt : startedAt ,
2026-07-04 01:06:04 +00:00
LLMProfiles : llmProfiles ,
Metadata : runMetadata ( * outputDir , * diagnosticsDir ) ,
2026-07-05 14:32:35 +00:00
Warnings : referenceWarnings ,
2026-07-04 01:06:04 +00:00
} )
if err != nil {
2026-07-04 01:11:57 +00:00
if output . Manifest . PipelineID != "" {
_ = runDir . WriteRunManifest ( output . Manifest )
}
return failPipelineCommand ( stderr , runDir , cfg . Diagnostics . Retention , fmt . Errorf ( "run pipeline %q: %w" , pipelineID , err ) )
2026-07-04 01:06:04 +00:00
}
2026-07-04 01:11:57 +00:00
runOutputDir := filepath . Join ( outputRoot ( * outputDir ) , runDir . RunID ( ) )
if err := runDir . WriteRunManifest ( output . Manifest ) ; err != nil {
return failPipelineCommand ( stderr , runDir , cfg . Diagnostics . Retention , fmt . Errorf ( "write diagnostics run manifest: %w" , err ) )
}
if err := runDir . WriteWarnings ( output . Warnings ) ; err != nil {
return failPipelineCommand ( stderr , runDir , cfg . Diagnostics . Retention , fmt . Errorf ( "write diagnostics warnings: %w" , err ) )
}
if err := runDir . WriteRunReport ( runReport {
RunID : runDir . RunID ( ) ,
PipelineID : effective . PipelineID ,
OutputPath : runOutputDir ,
DiagnosticsPath : runDir . Path ( ) ,
2026-07-07 18:58:23 +00:00
OutputCount : len ( output . NormalizeOutputs ) ,
2026-07-04 01:11:57 +00:00
RejectedCount : len ( output . Rejected ) ,
WarningCount : len ( output . Warnings ) ,
ValidationStatus : output . Manifest . ValidationStatus ,
} ) ; err != nil {
return failPipelineCommand ( stderr , runDir , cfg . Diagnostics . Retention , fmt . Errorf ( "write diagnostics run report: %w" , err ) )
}
2026-07-03 21:26:05 -05:00
if err := writeOutputFiles ( runOutputDir , output . OutputFiles ) ; err != nil {
return failPipelineCommand ( stderr , runDir , cfg . Diagnostics . Retention , err )
}
2026-07-04 01:11:57 +00:00
if err := runDir . ApplyRetention ( diagnostics . RetentionDecisionInput {
RetentionMode : cfg . Diagnostics . Retention ,
RunSucceeded : true ,
HasWarnings : len ( output . Warnings ) > 0 ,
} ) ; err != nil {
return failPipelineCommand ( stderr , runDir , cfg . Diagnostics . Retention , fmt . Errorf ( "apply diagnostics retention: %w" , err ) )
}
2026-07-07 18:58:23 +00:00
fmt . Fprintf ( stdout , "pipeline %q complete: outputs=%d rejected=%d output=%s\n" , effective . PipelineID , len ( output . NormalizeOutputs ) , len ( output . Rejected ) , runOutputDir )
2026-07-04 01:06:04 +00:00
if len ( output . Warnings ) > 0 {
fmt . Fprintf ( stderr , "notarius: run completed with %d warning(s)\n" , len ( output . Warnings ) )
}
return 0
}
2026-07-04 01:11:57 +00:00
type runReport struct {
RunID string ` json:"run_id" `
PipelineID string ` json:"pipeline_id" `
OutputPath string ` json:"output_path" `
DiagnosticsPath string ` json:"diagnostics_path,omitempty" `
2026-07-07 18:58:23 +00:00
OutputCount int ` json:"output_count" `
2026-07-04 01:11:57 +00:00
RejectedCount int ` json:"rejected_count" `
WarningCount int ` json:"warning_count" `
ValidationStatus string ` json:"validation_status,omitempty" `
}
func failPipelineCommand ( stderr io . Writer , runDir * diagnostics . RunDirectory , retention diagnostics . RetentionMode , err error ) int {
fmt . Fprintf ( stderr , "notarius: %v\n" , err )
if runDir != nil {
if logErr := runDir . WriteErrorLog ( err . Error ( ) ) ; logErr != nil {
fmt . Fprintf ( stderr , "notarius: write diagnostics error log: %v\n" , logErr )
}
if retentionErr := runDir . ApplyRetention ( diagnostics . RetentionDecisionInput {
RetentionMode : retention ,
RunSucceeded : false ,
} ) ; retentionErr != nil {
fmt . Fprintf ( stderr , "notarius: apply diagnostics retention: %v\n" , retentionErr )
}
}
return 1
}
func configSource ( configPath string ) string {
if strings . TrimSpace ( configPath ) != "" {
return "flag"
}
return "discovered"
}
func outputRoot ( outputDir string ) string {
if dir := strings . TrimSpace ( outputDir ) ; dir != "" {
return dir
}
return defaultOutputRoot
}
func writeOutputFiles ( runOutputDir string , files [ ] contracts . OutputFile ) error {
type outputTarget struct {
path string
file contracts . OutputFile
}
targets := make ( [ ] outputTarget , 0 , len ( files ) )
for _ , file := range files {
targetPath , err := outputFilePath ( runOutputDir , file . Name )
if err != nil {
return err
}
targets = append ( targets , outputTarget { path : targetPath , file : file } )
}
if err := os . MkdirAll ( runOutputDir , 0 o755 ) ; err != nil {
return fmt . Errorf ( "create output directory %q: %w" , runOutputDir , err )
}
for _ , target := range targets {
if err := os . MkdirAll ( filepath . Dir ( target . path ) , 0 o755 ) ; err != nil {
return fmt . Errorf ( "create output directory %q: %w" , filepath . Dir ( target . path ) , err )
}
if err := writeFileAtomic ( target . path , target . file . Bytes , 0 o644 ) ; err != nil {
return fmt . Errorf ( "write output file %q: %w" , target . file . Name , err )
}
}
return nil
}
func outputFilePath ( runOutputDir , logicalName string ) ( string , error ) {
name := strings . TrimSpace ( logicalName )
if name == "" {
return "" , fmt . Errorf ( "output file name must not be empty" )
}
if strings . Contains ( name , ` \ ` ) {
return "" , fmt . Errorf ( "output file name %q must use slash-separated relative paths" , name )
}
if path . IsAbs ( name ) || filepath . IsAbs ( name ) {
return "" , fmt . Errorf ( "output file name %q must be relative" , name )
}
if strings . Contains ( name , ".." ) {
return "" , fmt . Errorf ( "output file name %q must not contain .." , name )
}
cleaned := path . Clean ( name )
if cleaned == "." || cleaned != name {
return "" , fmt . Errorf ( "output file name %q must be clean" , name )
}
root , err := filepath . Abs ( runOutputDir )
if err != nil {
return "" , fmt . Errorf ( "resolve output directory %q: %w" , runOutputDir , err )
}
target , err := filepath . Abs ( filepath . Join ( root , filepath . FromSlash ( cleaned ) ) )
if err != nil {
return "" , fmt . Errorf ( "resolve output file %q: %w" , name , err )
}
rel , err := filepath . Rel ( root , target )
if err != nil {
return "" , fmt . Errorf ( "resolve output file %q: %w" , name , err )
}
if rel == "." || strings . HasPrefix ( rel , ".." + string ( filepath . Separator ) ) || rel == ".." {
return "" , fmt . Errorf ( "output file name %q resolves outside output directory" , name )
}
return target , nil
}
func writeFileAtomic ( path string , data [ ] byte , perm os . FileMode ) error {
dir := filepath . Dir ( path )
temp , err := os . CreateTemp ( dir , "." + filepath . Base ( path ) + ".tmp-*" )
if err != nil {
return err
}
tempPath := temp . Name ( )
removeTemp := true
defer func ( ) {
if removeTemp {
_ = os . Remove ( tempPath )
}
} ( )
if _ , err := temp . Write ( data ) ; err != nil {
_ = temp . Close ( )
return err
}
if err := temp . Chmod ( perm ) ; err != nil {
_ = temp . Close ( )
return err
}
if err := temp . Close ( ) ; err != nil {
return err
}
if err := os . Rename ( tempPath , path ) ; err != nil {
return err
}
removeTemp = false
return nil
}
2026-07-04 01:06:04 +00:00
func reorderRunArgs ( args [ ] string ) [ ] string {
var flags [ ] string
var positionals [ ] string
for i := 0 ; i < len ( args ) ; i ++ {
arg := args [ i ]
if arg == "--" {
positionals = append ( positionals , args [ i + 1 : ] ... )
break
}
if strings . HasPrefix ( arg , "-" ) {
flags = append ( flags , arg )
if runFlagTakesValue ( arg ) && ! strings . Contains ( arg , "=" ) && i + 1 < len ( args ) {
i ++
flags = append ( flags , args [ i ] )
}
continue
}
positionals = append ( positionals , arg )
}
return append ( flags , positionals ... )
}
func runFlagTakesValue ( arg string ) bool {
switch arg {
2026-07-05 17:51:36 +00:00
case "--config" , "--input" , "--only" , "--output-dir" , "--diagnostics-dir" , "--llm-profile" , "--session-id" , "--reference" , "--without-reference" :
2026-07-04 01:06:04 +00:00
return true
default :
return false
}
}
2026-07-05 17:51:36 +00:00
func validateRunFlagValues ( args [ ] string ) error {
for i , arg := range args {
if arg != "--session-id" {
continue
}
if i + 1 >= len ( args ) || strings . HasPrefix ( args [ i + 1 ] , "-" ) {
return fmt . Errorf ( "flag needs an argument: --session-id" )
}
}
return nil
}
2026-07-04 01:06:04 +00:00
func effectiveLLMProfileIDs ( resolved pipeline . ResolvedPipeline ) [ ] string {
seen := make ( map [ string ] struct { } )
add := func ( binding pipeline . ModuleBinding ) {
id := strings . TrimSpace ( binding . LLMProfile )
if id != "" {
seen [ id ] = struct { } { }
}
}
add ( resolved . Chunk )
for _ , lane := range resolved . ArtifactLanes {
add ( lane . Extract )
2026-07-07 19:10:55 +00:00
add ( lane . Merge )
2026-07-04 01:06:04 +00:00
add ( lane . Normalize )
}
ids := make ( [ ] string , 0 , len ( seen ) )
for id := range seen {
ids = append ( ids , id )
}
sort . Strings ( ids )
return ids
}
func runMetadata ( outputDir , diagnosticsDir string ) map [ string ] any {
metadata := make ( map [ string ] any )
if dir := strings . TrimSpace ( outputDir ) ; dir != "" {
metadata [ "output_dir" ] = dir
}
if dir := strings . TrimSpace ( diagnosticsDir ) ; dir != "" {
metadata [ "diagnostics_dir" ] = dir
}
if len ( metadata ) == 0 {
return nil
}
return metadata
}
2026-07-03 17:01:24 +00:00
func runConfig ( args [ ] string , stdout , stderr io . Writer , opts Options ) int {
if len ( args ) == 0 {
fmt . Fprintln ( stderr , "notarius: config requires a subcommand" )
writeUsage ( stderr )
return 2
}
switch args [ 0 ] {
case "validate" :
return runConfigValidate ( args [ 1 : ] , stdout , stderr , opts )
default :
fmt . Fprintf ( stderr , "notarius: unknown config subcommand %q\n" , args [ 0 ] )
writeUsage ( stderr )
return 2
}
}
func runConfigValidate ( args [ ] string , stdout , stderr io . Writer , opts Options ) int {
fs := flag . NewFlagSet ( "config validate" , flag . ContinueOnError )
fs . SetOutput ( io . Discard )
configPath := fs . String ( "config" , "" , "config file path" )
pipelineID := fs . String ( "pipeline" , "" , "pipeline ID" )
onlyRaw := fs . String ( "only" , "" , "comma-separated artifact lanes" )
if err := fs . Parse ( args ) ; err != nil {
fmt . Fprintf ( stderr , "notarius: %v\n" , err )
return 2
}
if fs . NArg ( ) != 0 {
fmt . Fprintf ( stderr , "notarius: unexpected argument %q\n" , fs . Arg ( 0 ) )
return 2
}
if strings . TrimSpace ( * onlyRaw ) != "" && strings . TrimSpace ( * pipelineID ) == "" {
fmt . Fprintln ( stderr , "notarius: --only requires --pipeline" )
return 2
}
2026-07-03 20:54:22 +00:00
only , err := parseOnly ( * onlyRaw )
if err != nil {
fmt . Fprintf ( stderr , "notarius: %v\n" , err )
return 2
}
2026-07-03 17:01:24 +00:00
cfg , path , err := loadConfig ( * configPath , opts )
if err != nil {
fmt . Fprintf ( stderr , "notarius: %v\n" , err )
return 1
}
if strings . TrimSpace ( * pipelineID ) != "" {
2026-07-04 00:59:32 +00:00
catalog , err := effectiveCatalog ( opts )
if err != nil {
fmt . Fprintf ( stderr , "notarius: %v\n" , err )
return 1
}
2026-07-05 18:03:23 +00:00
effective , err := cfg . Resolve ( config . ResolveInput {
2026-07-03 17:01:24 +00:00
PipelineID : * pipelineID ,
2026-07-03 20:54:22 +00:00
Only : only ,
2026-07-04 00:59:32 +00:00
Catalog : catalog ,
2026-07-05 18:03:23 +00:00
} )
if err != nil {
fmt . Fprintf ( stderr , "notarius: %v\n" , err )
return 1
}
if err := validateExplicitScriptoriumProfiles ( context . Background ( ) , effective . Config , effectiveLLMProfileIDs ( effective . ResolvedPipeline ) ) ; err != nil {
2026-07-03 17:01:24 +00:00
fmt . Fprintf ( stderr , "notarius: %v\n" , err )
return 1
}
fmt . Fprintf ( stdout , "config %q is valid for pipeline %q\n" , path , strings . TrimSpace ( * pipelineID ) )
return 0
}
if err := cfg . Validate ( ) ; err != nil {
fmt . Fprintf ( stderr , "notarius: %v\n" , err )
return 1
}
fmt . Fprintf ( stdout , "config %q is valid\n" , path )
return 0
}
func runPipelines ( args [ ] string , stdout , stderr io . Writer , opts Options ) int {
if len ( args ) == 0 {
fmt . Fprintln ( stderr , "notarius: pipelines requires a subcommand" )
writeUsage ( stderr )
return 2
}
switch args [ 0 ] {
case "list" :
return runPipelinesList ( args [ 1 : ] , stdout , stderr , opts )
default :
fmt . Fprintf ( stderr , "notarius: unknown pipelines subcommand %q\n" , args [ 0 ] )
writeUsage ( stderr )
return 2
}
}
func runPipelinesList ( args [ ] string , stdout , stderr io . Writer , opts Options ) int {
fs := flag . NewFlagSet ( "pipelines list" , flag . ContinueOnError )
fs . SetOutput ( io . Discard )
configPath := fs . String ( "config" , "" , "config file path" )
jsonOutput := fs . Bool ( "json" , false , "write JSON output" )
if err := fs . Parse ( args ) ; err != nil {
fmt . Fprintf ( stderr , "notarius: %v\n" , err )
return 2
}
if fs . NArg ( ) != 0 {
fmt . Fprintf ( stderr , "notarius: unexpected argument %q\n" , fs . Arg ( 0 ) )
return 2
}
cfg , _ , err := loadConfig ( * configPath , opts )
if err != nil {
fmt . Fprintf ( stderr , "notarius: %v\n" , err )
return 1
}
if err := cfg . Validate ( ) ; err != nil {
fmt . Fprintf ( stderr , "notarius: %v\n" , err )
return 1
}
ids := sortedPipelineIDs ( cfg )
if * jsonOutput {
payload := struct {
Pipelines [ ] string ` json:"pipelines" `
} { Pipelines : ids }
encoded , err := json . Marshal ( payload )
if err != nil {
fmt . Fprintf ( stderr , "notarius: marshal pipeline list: %v\n" , err )
return 1
}
fmt . Fprintf ( stdout , "%s\n" , encoded )
return 0
}
for _ , id := range ids {
fmt . Fprintln ( stdout , id )
}
return 0
}
func loadConfig ( configPath string , opts Options ) ( config . Config , string , error ) {
path , err := discoverConfigPath ( configPath , opts )
if err != nil {
return config . Config { } , "" , err
}
fileCfg , err := config . LoadFileConfig ( path )
if err != nil {
return config . Config { } , "" , err
}
cfg := config . Default ( )
if err := cfg . ApplyFileConfigWithLookup ( fileCfg , opts . LookupEnv ) ; err != nil {
return config . Config { } , "" , err
}
if err := cfg . ApplyEnvOverridesWithLookup ( opts . LookupEnv ) ; err != nil {
return config . Config { } , "" , err
}
return cfg , path , nil
}
func discoverConfigPath ( configPath string , opts Options ) ( string , error ) {
if path := strings . TrimSpace ( configPath ) ; path != "" {
if err := requireConfigFile ( path ) ; err != nil {
return "" , err
}
return path , nil
}
if path , ok := opts . LookupEnv ( "NOTARIUS_CONFIG" ) ; ok && strings . TrimSpace ( path ) != "" {
path = strings . TrimSpace ( path )
if err := requireConfigFile ( path ) ; err != nil {
return "" , err
}
return path , nil
}
if _ , err := os . Stat ( defaultConfigPath ) ; err == nil {
return defaultConfigPath , nil
} else if err != nil && ! os . IsNotExist ( err ) {
return "" , fmt . Errorf ( "check default config %q: %w" , defaultConfigPath , err )
}
return "" , fmt . Errorf ( "config file not found; pass --config or set NOTARIUS_CONFIG" )
}
func requireConfigFile ( path string ) error {
info , err := os . Stat ( path )
if err != nil {
return fmt . Errorf ( "config file %q is not available: %w" , path , err )
}
if info . IsDir ( ) {
return fmt . Errorf ( "config file %q is a directory" , path )
}
return nil
}
2026-07-03 20:54:22 +00:00
func parseOnly ( raw string ) ( [ ] string , error ) {
2026-07-03 17:01:24 +00:00
if strings . TrimSpace ( raw ) == "" {
2026-07-03 20:54:22 +00:00
return nil , nil
2026-07-03 17:01:24 +00:00
}
parts := strings . Split ( raw , "," )
result := make ( [ ] string , 0 , len ( parts ) )
for _ , part := range parts {
2026-07-03 20:54:22 +00:00
trimmed := strings . TrimSpace ( part )
if trimmed == "" {
return nil , fmt . Errorf ( "--only must contain comma-separated non-empty artifact lane IDs" )
2026-07-03 17:01:24 +00:00
}
2026-07-03 20:54:22 +00:00
result = append ( result , trimmed )
2026-07-03 17:01:24 +00:00
}
2026-07-03 20:54:22 +00:00
return result , nil
2026-07-03 17:01:24 +00:00
}
2026-07-05 14:27:23 +00:00
type stringListFlag [ ] string
func ( flag * stringListFlag ) String ( ) string {
if flag == nil {
return ""
}
return strings . Join ( * flag , "," )
}
func ( flag * stringListFlag ) Set ( value string ) error {
* flag = append ( * flag , value )
return nil
}
2026-07-05 17:51:36 +00:00
type sessionIDFlag struct {
value string
set bool
}
func ( flag * sessionIDFlag ) String ( ) string {
if flag == nil {
return ""
}
return flag . value
}
func ( flag * sessionIDFlag ) Set ( value string ) error {
flag . value = value
flag . set = true
return nil
}
2026-07-05 14:27:23 +00:00
type cliReferenceRequest struct {
2026-07-05 16:31:23 +00:00
Selector cliReferenceSelector
2026-07-05 14:27:23 +00:00
Source string
}
type cliReferenceUnbindRequest struct {
2026-07-05 16:31:23 +00:00
Selector cliReferenceSelector
}
type cliReferenceSelector struct {
2026-07-05 14:27:23 +00:00
LaneID string
2026-07-05 16:31:23 +00:00
Stage pipeline . ModuleStage
2026-07-05 14:27:23 +00:00
SlotName string
}
func parseReferenceFlags ( values [ ] string ) ( [ ] cliReferenceRequest , error ) {
if len ( values ) == 0 {
return nil , nil
}
requests := make ( [ ] cliReferenceRequest , 0 , len ( values ) )
for _ , raw := range values {
name , source , ok := strings . Cut ( raw , "=" )
if ! ok {
return nil , fmt . Errorf ( "--reference must use slot=path or lane.slot=path" )
}
if strings . TrimSpace ( source ) == "" {
return nil , fmt . Errorf ( "--reference path must not be empty; use --without-reference to unbind" )
}
2026-07-05 16:31:23 +00:00
selector , err := parseReferenceSelector ( name , "--reference" )
2026-07-05 14:27:23 +00:00
if err != nil {
return nil , err
}
requests = append ( requests , cliReferenceRequest {
2026-07-05 16:31:23 +00:00
Selector : selector ,
2026-07-05 14:27:23 +00:00
Source : strings . TrimSpace ( source ) ,
} )
}
return requests , nil
}
func parseReferenceUnbindFlags ( values [ ] string ) ( [ ] cliReferenceUnbindRequest , error ) {
if len ( values ) == 0 {
return nil , nil
}
requests := make ( [ ] cliReferenceUnbindRequest , 0 , len ( values ) )
for _ , raw := range values {
if strings . Contains ( raw , "=" ) {
2026-07-05 16:31:23 +00:00
return nil , fmt . Errorf ( "--without-reference must use a reference selector without =path" )
2026-07-05 14:27:23 +00:00
}
2026-07-05 16:31:23 +00:00
selector , err := parseReferenceSelector ( raw , "--without-reference" )
2026-07-05 14:27:23 +00:00
if err != nil {
return nil , err
}
requests = append ( requests , cliReferenceUnbindRequest {
2026-07-05 16:31:23 +00:00
Selector : selector ,
2026-07-05 14:27:23 +00:00
} )
}
return requests , nil
}
2026-07-05 16:31:23 +00:00
func parseReferenceSelector ( raw string , flagName string ) ( cliReferenceSelector , error ) {
2026-07-05 14:27:23 +00:00
selector := strings . TrimSpace ( raw )
if selector == "" {
2026-07-05 16:31:23 +00:00
return cliReferenceSelector { } , fmt . Errorf ( "%s reference slot must not be empty" , flagName )
2026-07-05 14:27:23 +00:00
}
2026-07-05 16:31:23 +00:00
parts := strings . Split ( selector , "." )
for _ , part := range parts {
if strings . TrimSpace ( part ) == "" {
return cliReferenceSelector { } , fmt . Errorf ( "%s must use non-empty reference selector values" , flagName )
}
2026-07-05 14:27:23 +00:00
}
2026-07-05 16:31:23 +00:00
switch len ( parts ) {
case 1 :
return cliReferenceSelector { SlotName : strings . TrimSpace ( parts [ 0 ] ) } , nil
case 2 :
first := strings . TrimSpace ( parts [ 0 ] )
slotName := strings . TrimSpace ( parts [ 1 ] )
if first == string ( pipeline . StageChunk ) {
return cliReferenceSelector { Stage : pipeline . StageChunk , SlotName : slotName } , nil
}
2026-07-07 19:10:55 +00:00
if first == string ( pipeline . StageMerge ) {
return cliReferenceSelector { Stage : pipeline . StageMerge , SlotName : slotName } , nil
}
2026-07-05 16:31:23 +00:00
return cliReferenceSelector { LaneID : first , SlotName : slotName } , nil
case 3 :
laneID := strings . TrimSpace ( parts [ 0 ] )
stage := pipeline . ModuleStage ( strings . TrimSpace ( parts [ 1 ] ) )
slotName := strings . TrimSpace ( parts [ 2 ] )
2026-07-07 19:10:55 +00:00
if stage != pipeline . StageExtract && stage != pipeline . StageMerge && stage != pipeline . StageNormalize {
return cliReferenceSelector { } , fmt . Errorf ( "%s lane-qualified selector must use lane.extract.slot, lane.merge.slot, or lane.normalize.slot" , flagName )
2026-07-05 14:27:23 +00:00
}
2026-07-05 16:31:23 +00:00
return cliReferenceSelector { LaneID : laneID , Stage : stage , SlotName : slotName } , nil
default :
2026-07-07 19:10:55 +00:00
return cliReferenceSelector { } , fmt . Errorf ( "%s must use slot, chunk.slot, merge.slot, lane.slot, lane.extract.slot, lane.merge.slot, or lane.normalize.slot" , flagName )
2026-07-05 14:27:23 +00:00
}
}
func resolveCLIReferenceRequests (
cfg config . Config ,
pipelineID string ,
only [ ] string ,
catalog pipeline . ModuleCatalog ,
referenceRequests [ ] cliReferenceRequest ,
unbindRequests [ ] cliReferenceUnbindRequest ,
) ( [ ] pipeline . ReferenceBinding , [ ] pipeline . ReferenceUnbind , error ) {
if len ( referenceRequests ) == 0 && len ( unbindRequests ) == 0 {
return nil , nil , nil
}
2026-07-05 16:31:23 +00:00
targets , err := selectedReferenceTargets ( cfg , pipelineID , only , catalog )
2026-07-05 14:27:23 +00:00
if err != nil {
return nil , nil , err
}
overrides := make ( [ ] pipeline . ReferenceBinding , 0 , len ( referenceRequests ) )
for _ , request := range referenceRequests {
2026-07-05 16:31:23 +00:00
target , err := resolveCLIReferenceTarget ( targets , request . Selector )
2026-07-05 14:27:23 +00:00
if err != nil {
return nil , nil , err
}
overrides = append ( overrides , pipeline . ReferenceBinding {
2026-07-05 16:31:23 +00:00
Stage : target . stage ,
LaneID : target . laneID ,
SlotName : request . Selector . SlotName ,
2026-07-05 14:27:23 +00:00
Source : request . Source ,
BindingSource : contracts . ReferenceBindingSourceCLI ,
} )
}
unbinds := make ( [ ] pipeline . ReferenceUnbind , 0 , len ( unbindRequests ) )
for _ , request := range unbindRequests {
2026-07-05 16:31:23 +00:00
target , err := resolveCLIReferenceTarget ( targets , request . Selector )
2026-07-05 14:27:23 +00:00
if err != nil {
return nil , nil , err
}
unbinds = append ( unbinds , pipeline . ReferenceUnbind {
2026-07-05 16:31:23 +00:00
Stage : target . stage ,
LaneID : target . laneID ,
SlotName : request . Selector . SlotName ,
2026-07-05 14:27:23 +00:00
} )
}
return overrides , unbinds , nil
}
2026-07-05 16:31:23 +00:00
type selectedReferenceTarget struct {
laneID string
stage pipeline . ModuleStage
module string
slots map [ string ] struct { }
2026-07-05 14:27:23 +00:00
}
2026-07-05 16:31:23 +00:00
func selectedReferenceTargets ( cfg config . Config , pipelineID string , only [ ] string , catalog pipeline . ModuleCatalog ) ( [ ] selectedReferenceTarget , error ) {
2026-07-05 14:27:23 +00:00
profile , ok := lookupCLIReferencePipeline ( cfg . Pipelines , pipelineID )
if ! ok {
return nil , fmt . Errorf ( "pipeline %q is not configured" , strings . TrimSpace ( pipelineID ) )
}
lanesByID := make ( map [ string ] pipeline . ArtifactLaneProfile , len ( profile . Artifacts ) )
for rawLaneID , lane := range profile . Artifacts {
laneID := strings . TrimSpace ( rawLaneID )
if laneID == "" {
return nil , fmt . Errorf ( "pipeline %q artifact lane id must not be empty" , strings . TrimSpace ( pipelineID ) )
}
if _ , ok := lanesByID [ laneID ] ; ok {
return nil , fmt . Errorf ( "pipeline %q artifact lane %q is duplicated after trimming" , strings . TrimSpace ( pipelineID ) , laneID )
}
lanesByID [ laneID ] = lane
}
selectedIDs := make ( [ ] string , 0 , len ( lanesByID ) )
if len ( only ) == 0 {
for laneID := range lanesByID {
selectedIDs = append ( selectedIDs , laneID )
}
} else {
seen := make ( map [ string ] struct { } , len ( only ) )
for _ , rawLaneID := range only {
laneID := strings . TrimSpace ( rawLaneID )
if laneID == "" {
return nil , fmt . Errorf ( "pipeline %q selected artifact lane id must not be empty" , strings . TrimSpace ( pipelineID ) )
}
if _ , ok := lanesByID [ laneID ] ; ! ok {
return nil , fmt . Errorf ( "pipeline %q selected artifact lane %q is not declared" , strings . TrimSpace ( pipelineID ) , laneID )
}
if _ , ok := seen [ laneID ] ; ! ok {
selectedIDs = append ( selectedIDs , laneID )
seen [ laneID ] = struct { } { }
}
}
}
sort . Strings ( selectedIDs )
2026-07-07 19:10:55 +00:00
targets := make ( [ ] selectedReferenceTarget , 0 , 1 + len ( selectedIDs ) * 3 )
2026-07-05 16:31:23 +00:00
chunk := pipeline . Binding ( profile . Chunk . Module )
chunk . Module = strings . TrimSpace ( profile . Chunk . Module )
if chunk . Module == "" {
chunk . Module = pipeline . DefaultChunkModule
}
chunkSpec , err := cliReferenceChunkerSpec ( catalog , chunk . Module )
if err != nil {
return nil , fmt . Errorf ( "pipeline %q chunk module %q: %w" , strings . TrimSpace ( pipelineID ) , chunk . Module , err )
}
targets = append ( targets , selectedReferenceTarget {
stage : pipeline . StageChunk ,
module : chunk . Module ,
slots : referenceSlotSet ( chunkSpec . ReferenceSlots ) ,
} )
2026-07-05 14:27:23 +00:00
for _ , laneID := range selectedIDs {
lane := lanesByID [ laneID ]
extractModule := strings . TrimSpace ( lane . Extract . Module )
if extractModule == "" {
return nil , fmt . Errorf ( "pipeline %q lane %q extract module must not be empty" , strings . TrimSpace ( pipelineID ) , laneID )
}
2026-07-05 16:31:23 +00:00
extractSpec , err := cliReferenceExtractorSpec ( catalog , extractModule )
if err != nil {
return nil , fmt . Errorf ( "pipeline %q lane %q extract module %q: %w" , strings . TrimSpace ( pipelineID ) , laneID , extractModule , err )
2026-07-05 14:27:23 +00:00
}
2026-07-05 16:31:23 +00:00
targets = append ( targets , selectedReferenceTarget {
laneID : laneID ,
stage : pipeline . StageExtract ,
module : extractModule ,
slots : referenceSlotSet ( extractSpec . ReferenceSlots ) ,
} )
2026-07-07 19:10:55 +00:00
mergeModule := strings . TrimSpace ( lane . Merge . Module )
if mergeModule == "" {
mergeModule = pipeline . DefaultMergeModule
}
mergeSpec , err := cliReferenceMergerSpec ( catalog , mergeModule )
if err != nil {
return nil , fmt . Errorf ( "pipeline %q lane %q merge module %q: %w" , strings . TrimSpace ( pipelineID ) , laneID , mergeModule , err )
}
targets = append ( targets , selectedReferenceTarget {
laneID : laneID ,
stage : pipeline . StageMerge ,
module : mergeModule ,
slots : referenceSlotSet ( mergeSpec . ReferenceSlots ) ,
} )
2026-07-05 16:31:23 +00:00
normalizeModule := strings . TrimSpace ( lane . Normalize . Module )
if normalizeModule == "" {
normalizeModule = pipeline . DefaultNormalizeModule
2026-07-05 14:27:23 +00:00
}
2026-07-05 16:31:23 +00:00
normalizeSpec , err := cliReferenceNormalizerSpec ( catalog , normalizeModule )
if err != nil {
return nil , fmt . Errorf ( "pipeline %q lane %q normalize module %q: %w" , strings . TrimSpace ( pipelineID ) , laneID , normalizeModule , err )
2026-07-05 14:27:23 +00:00
}
2026-07-05 16:31:23 +00:00
targets = append ( targets , selectedReferenceTarget {
laneID : laneID ,
stage : pipeline . StageNormalize ,
module : normalizeModule ,
slots : referenceSlotSet ( normalizeSpec . ReferenceSlots ) ,
} )
2026-07-05 14:27:23 +00:00
}
2026-07-05 16:31:23 +00:00
return targets , nil
2026-07-05 14:27:23 +00:00
}
func lookupCLIReferencePipeline ( profiles map [ string ] pipeline . PipelineProfile , pipelineID string ) ( pipeline . PipelineProfile , bool ) {
pipelineID = strings . TrimSpace ( pipelineID )
for rawID , profile := range profiles {
if strings . TrimSpace ( rawID ) == pipelineID {
return profile , true
}
}
return pipeline . PipelineProfile { } , false
}
2026-07-05 16:31:23 +00:00
func cliReferenceChunkerSpec ( catalog pipeline . ModuleCatalog , module string ) ( pipeline . ModuleSpec , error ) {
if catalog . Chunkers == nil {
return pipeline . ModuleSpec { } , fmt . Errorf ( "module %q is not registered" , module )
}
spec , ok := catalog . Chunkers . Spec ( module )
if ! ok {
return pipeline . ModuleSpec { } , fmt . Errorf ( "module %q is not registered" , module )
}
return spec , nil
}
func cliReferenceExtractorSpec ( catalog pipeline . ModuleCatalog , module string ) ( pipeline . ModuleSpec , error ) {
if catalog . Extractors == nil {
return pipeline . ModuleSpec { } , fmt . Errorf ( "module %q is not registered" , module )
}
spec , ok := catalog . Extractors . Spec ( module )
if ! ok {
return pipeline . ModuleSpec { } , fmt . Errorf ( "module %q is not registered" , module )
}
return spec , nil
}
2026-07-07 19:10:55 +00:00
func cliReferenceMergerSpec ( catalog pipeline . ModuleCatalog , module string ) ( pipeline . ModuleSpec , error ) {
if catalog . Mergers == nil {
return pipeline . ModuleSpec { } , fmt . Errorf ( "module %q is not registered" , module )
}
spec , ok := catalog . Mergers . Spec ( module )
if ! ok {
return pipeline . ModuleSpec { } , fmt . Errorf ( "module %q is not registered" , module )
}
return spec , nil
}
2026-07-05 16:31:23 +00:00
func cliReferenceNormalizerSpec ( catalog pipeline . ModuleCatalog , module string ) ( pipeline . ModuleSpec , error ) {
if catalog . Normalizers == nil {
return pipeline . ModuleSpec { } , fmt . Errorf ( "module %q is not registered" , module )
}
spec , ok := catalog . Normalizers . Spec ( module )
if ! ok {
return pipeline . ModuleSpec { } , fmt . Errorf ( "module %q is not registered" , module )
}
return spec , nil
}
func referenceSlotSet ( slots [ ] contracts . ReferenceSlot ) map [ string ] struct { } {
slotSet := make ( map [ string ] struct { } , len ( slots ) )
for _ , slot := range slots {
slotSet [ slot . Name ] = struct { } { }
}
return slotSet
}
func resolveCLIReferenceTarget ( targets [ ] selectedReferenceTarget , selector cliReferenceSelector ) ( selectedReferenceTarget , error ) {
slotName := strings . TrimSpace ( selector . SlotName )
if slotName == "" {
return selectedReferenceTarget { } , fmt . Errorf ( "reference slot must not be empty" )
}
if selector . Stage == pipeline . StageChunk {
for _ , target := range targets {
if target . stage != pipeline . StageChunk {
continue
}
if _ , ok := target . slots [ slotName ] ; ! ok {
return selectedReferenceTarget { } , fmt . Errorf ( "reference slot %q is not declared by chunk module %q" , slotName , target . module )
}
return target , nil
}
return selectedReferenceTarget { } , fmt . Errorf ( "reference chunk target is not selected" )
}
2026-07-07 19:10:55 +00:00
if selector . Stage == pipeline . StageExtract || selector . Stage == pipeline . StageMerge || selector . Stage == pipeline . StageNormalize {
if selector . LaneID == "" && selector . Stage == pipeline . StageMerge {
return resolveCLIReferenceStageTarget ( targets , selector . Stage , slotName )
}
2026-07-05 16:31:23 +00:00
for _ , target := range targets {
if target . laneID == selector . LaneID && target . stage == selector . Stage {
if _ , ok := target . slots [ slotName ] ; ! ok {
return selectedReferenceTarget { } , fmt . Errorf ( "reference slot %q is not declared by selected %s target %q" , slotName , selector . Stage , targetLabel ( target ) )
2026-07-05 14:27:23 +00:00
}
2026-07-05 16:31:23 +00:00
return target , nil
2026-07-05 14:27:23 +00:00
}
}
2026-07-05 16:31:23 +00:00
return selectedReferenceTarget { } , fmt . Errorf ( "reference lane %q is not selected" , selector . LaneID )
}
if strings . TrimSpace ( selector . LaneID ) != "" {
return resolveCLIReferenceLaneTarget ( targets , strings . TrimSpace ( selector . LaneID ) , slotName )
}
return resolveCLIReferenceFlatTarget ( targets , slotName )
}
2026-07-07 19:10:55 +00:00
func resolveCLIReferenceStageTarget ( targets [ ] selectedReferenceTarget , stage pipeline . ModuleStage , slotName string ) ( selectedReferenceTarget , error ) {
matches := make ( [ ] selectedReferenceTarget , 0 , 2 )
for _ , target := range targets {
if target . stage != stage {
continue
}
if _ , ok := target . slots [ slotName ] ; ok {
matches = append ( matches , target )
}
}
switch len ( matches ) {
case 0 :
return selectedReferenceTarget { } , fmt . Errorf ( "reference slot %q is not declared by any selected %s target" , slotName , stage )
case 1 :
return matches [ 0 ] , nil
default :
return selectedReferenceTarget { } , fmt . Errorf ( "reference slot %q is declared by multiple selected %s targets (%s); use a more specific selector such as %s" , slotName , stage , targetList ( matches ) , selectorSuggestions ( matches , slotName ) )
}
}
2026-07-05 16:31:23 +00:00
func resolveCLIReferenceLaneTarget ( targets [ ] selectedReferenceTarget , laneID string , slotName string ) ( selectedReferenceTarget , error ) {
laneSelected := false
matches := make ( [ ] selectedReferenceTarget , 0 , 2 )
for _ , target := range targets {
if target . laneID != laneID {
continue
}
laneSelected = true
if _ , ok := target . slots [ slotName ] ; ok {
matches = append ( matches , target )
}
}
if ! laneSelected {
return selectedReferenceTarget { } , fmt . Errorf ( "reference lane %q is not selected" , laneID )
}
switch len ( matches ) {
case 0 :
return selectedReferenceTarget { } , fmt . Errorf ( "reference slot %q is not declared by selected lane %q" , slotName , laneID )
case 1 :
return matches [ 0 ] , nil
default :
2026-07-07 19:10:55 +00:00
return selectedReferenceTarget { } , fmt . Errorf ( "reference slot %q is declared by multiple selected targets in lane %q (%s); use a more specific selector such as %s" , slotName , laneID , targetList ( matches ) , selectorSuggestions ( matches , slotName ) )
2026-07-05 14:27:23 +00:00
}
2026-07-05 16:31:23 +00:00
}
2026-07-05 14:27:23 +00:00
2026-07-05 16:31:23 +00:00
func resolveCLIReferenceFlatTarget ( targets [ ] selectedReferenceTarget , slotName string ) ( selectedReferenceTarget , error ) {
matches := make ( [ ] selectedReferenceTarget , 0 , 2 )
for _ , target := range targets {
if _ , ok := target . slots [ slotName ] ; ok {
matches = append ( matches , target )
2026-07-05 14:27:23 +00:00
}
}
switch len ( matches ) {
case 0 :
2026-07-05 16:31:23 +00:00
return selectedReferenceTarget { } , fmt . Errorf ( "reference slot %q is not declared by any selected reference target" , slotName )
2026-07-05 14:27:23 +00:00
case 1 :
return matches [ 0 ] , nil
default :
2026-07-05 16:31:23 +00:00
return selectedReferenceTarget { } , fmt . Errorf ( "reference slot %q is declared by multiple selected targets (%s); use a more specific selector such as %s" , slotName , targetList ( matches ) , selectorSuggestions ( matches , slotName ) )
}
}
func targetList ( targets [ ] selectedReferenceTarget ) string {
labels := make ( [ ] string , 0 , len ( targets ) )
for _ , target := range targets {
labels = append ( labels , targetLabel ( target ) )
}
sort . Strings ( labels )
return strings . Join ( labels , ", " )
}
func targetLabel ( target selectedReferenceTarget ) string {
if target . stage == pipeline . StageChunk {
return "chunk"
}
return target . laneID + "." + string ( target . stage )
}
func selectorSuggestions ( targets [ ] selectedReferenceTarget , slotName string ) string {
suggestions := make ( [ ] string , 0 , len ( targets ) )
for _ , target := range targets {
if target . stage == pipeline . StageChunk {
suggestions = append ( suggestions , "chunk." + slotName )
continue
}
suggestions = append ( suggestions , target . laneID + "." + string ( target . stage ) + "." + slotName )
2026-07-05 14:27:23 +00:00
}
2026-07-05 16:31:23 +00:00
sort . Strings ( suggestions )
return strings . Join ( suggestions , " or " )
2026-07-05 14:27:23 +00:00
}
2026-07-03 17:01:24 +00:00
func sortedPipelineIDs ( cfg config . Config ) [ ] string {
ids := make ( [ ] string , 0 , len ( cfg . Pipelines ) )
for id := range cfg . Pipelines {
ids = append ( ids , strings . TrimSpace ( id ) )
}
sort . Strings ( ids )
return ids
}