diff --git a/cmd/specgen/main.go b/cmd/specgen/main.go new file mode 100644 index 000000000..ecfc684c1 --- /dev/null +++ b/cmd/specgen/main.go @@ -0,0 +1,280 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "os" + "strings" + + "github.com/project-dalec/dalec/internal/specgen" +) + +func main() { + var ( + repo = flag.String("repo", ".", "Path to the repository to analyze") + out = flag.String("out", "dalec.yml", "Output path for generated Dalec spec") + source = flag.String("source", "context", "Source mode: context|git") + force = flag.String("type", "", "Force repo type: go|rust|node|python (optional)") + printY = flag.Bool("print", false, "Print YAML to stdout instead of writing a file") + + // New preferred control. + outputProfile = flag.String( + "output-profile", + "auto", + "Preferred output profile: auto|package|package+container|container|sysext|windowscross", + ) + + // Legacy controls kept for compatibility. + intent = flag.String("intent", "auto", "Legacy output intent: auto|package|package+container|container-only|sysext|windowscross") + targetFamily = flag.String("target-family", "auto", "Target family: auto|rpm|deb|both|windows") + mainComp = flag.String("main-component", "", "Optional explicit primary binary/app/service name for complex repos") + withTests = flag.String("with-tests", "auto", "Test generation mode: auto|always|never") + emitTargets = flag.Bool("emit-targets", true, "Emit target-aware planning information in the baseline plan") + + bundleOut = flag.String("bundle-out", "", "Optional path to write the full baseline bundle JSON") + analysisOut = flag.String("analysis-out", "", "Optional path to write analysis JSON") + planOut = flag.String("plan-out", "", "Optional path to write plan JSON") + unresolvedOut = flag.String("unresolved-out", "", "Optional path to write unresolved-items JSON") + + preferGit = flag.Bool("prefer-git-source", true, "Prefer git source when repo metadata is available") + emitArgs = flag.Bool("emit-args", true, "Emit VERSION/REVISION-style baseline args when useful") + richPlan = flag.Bool("rich-plan", true, "Preserve alternatives, decisions, and richer baseline planning context") + strict = flag.Bool("strict", false, "Fail if the baseline contains high-severity unresolved items") + + // User-intent flags — control what to build without needing AI refinement. + binaryNames = flag.String( + "binaries", "", + "Comma-separated explicit binary names for multi-binary repos, e.g. myctl,myagent.\n"+ + "Overrides component-selection scoring. First name becomes the primary component.", + ) + packageName = flag.String("package-name", "", "Explicit package name to emit in the baseline spec") + binaryName = flag.String("binary-name", "", "Explicit primary installed binary name when it differs from the package name") + buildStyle = flag.String("build-style", "", "Explicit baseline build style, e.g. go-make, go-simple, rust-workspace") + buildTarget = flag.String("build-target", "", "Explicit primary source build target, e.g. ./cmd/operator or ./cmd/gh") + entrypoint = flag.String("entrypoint", "", "Explicit runtime entrypoint for image/tests") + cmdFlag = flag.String("cmd", "", "Explicit runtime command/arguments for image/tests") + extraBuildDeps = flag.String( + "extra-build-deps", "", + "Comma-separated extra build-time package dependencies to inject into\n"+ + "spec.dependencies.build, e.g. libssl-dev,zlib1g-dev,pkg-config.", + ) + extraRuntimeDeps = flag.String( + "extra-runtime-deps", "", + "Comma-separated extra runtime package dependencies to inject into\n"+ + "spec.dependencies.runtime.", + ) + versionVarPath = flag.String( + "version-var", "", + "Fully-qualified Go ldflags version variable path used to inject the\n"+ + "build-time version string, e.g. main.version or\n"+ + "github.com/foo/bar/cmd.Version.\n"+ + "When set the baseline emits: go build -ldflags \"-X =${VERSION}\".", + ) + cgoEnabled = flag.String( + "cgo", "auto", + "CGO mode for Go builds: auto|true|false.\n"+ + "Default: auto (baseline keeps CGO disabled unless explicitly enabled).", + ) + userHints = flag.String( + "hints", "", + "Free-text hints forwarded to the AI refinement stage.\n"+ + "Ignored by the deterministic baseline generator.", + ) + ) + + flag.Parse() + + profile := specgen.ParseOutputProfile(*outputProfile) + legacyIntent := specgen.ParseIntentMode(*intent) + + // output-profile is the preferred top-level knob. + resolvedIntent := legacyIntent + if profile != specgen.OutputProfileAuto { + profileIntent := specgen.IntentFromOutputProfile(profile) + if legacyIntent != "" && legacyIntent != specgen.IntentAuto && legacyIntent != profileIntent { + fmt.Fprintf( + os.Stderr, + "specgen error: conflicting flags: --output-profile=%s maps to intent=%s but --intent=%s was also supplied\n", + profile, + profileIntent, + legacyIntent, + ) + os.Exit(1) + } + resolvedIntent = profileIntent + } + if resolvedIntent == "" { + resolvedIntent = specgen.IntentAuto + } + + cgoPtr, err := parseOptionalBool(*cgoEnabled) + if err != nil { + fmt.Fprintf( + os.Stderr, + "specgen error: invalid --cgo value %q: %v\n", + *cgoEnabled, err, + ) + os.Exit(1) + } + + opts := specgen.Options{ + RepoDir: *repo, + OutFile: *out, + SourceMode: *source, + ForcedType: *force, + SyntaxImage: "ghcr.io/project-dalec/dalec/frontend:latest", + Intent: resolvedIntent, + TargetFamily: specgen.ParseTargetFamily(*targetFamily), + MainComponent: strings.TrimSpace(*mainComp), + TestMode: specgen.ParseTestMode(*withTests), + EmitTargets: *emitTargets, + BundleOut: strings.TrimSpace(*bundleOut), + PreferGitSource: *preferGit, + EmitArgs: *emitArgs, + RichPlan: *richPlan, + BinaryNames: splitTrimmed(*binaryNames), + PackageName: strings.TrimSpace(*packageName), + BinaryName: strings.TrimSpace(*binaryName), + BuildStyle: strings.TrimSpace(*buildStyle), + BuildTarget: strings.TrimSpace(*buildTarget), + Entrypoint: strings.TrimSpace(*entrypoint), + Command: strings.TrimSpace(*cmdFlag), + ExtraBuildDeps: splitTrimmed(*extraBuildDeps), + ExtraRuntimeDeps: splitTrimmed(*extraRuntimeDeps), + VersionVarPath: strings.TrimSpace(*versionVarPath), + CGOEnabled: cgoPtr, + UserHints: strings.TrimSpace(*userHints), + } + + res, err := specgen.GenerateBaseline(context.Background(), opts) + if err != nil { + fmt.Fprintf(os.Stderr, "specgen error: %v\n", err) + os.Exit(1) + } + + for _, w := range res.Warnings { + fmt.Fprintf(os.Stderr, "warning: %s\n", w) + } + + if *strict { + if err := failOnHighSeverityUnresolved(res.Unresolved); err != nil { + fmt.Fprintf(os.Stderr, "strict validation failed: %v\n", err) + os.Exit(1) + } + } + + if err := writeJSONIfRequested(opts.BundleOut, res.Bundle); err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + os.Exit(1) + } + if err := writeJSONIfRequested(*analysisOut, res.Analysis); err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + os.Exit(1) + } + if err := writeJSONIfRequested(*planOut, res.Plan); err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + os.Exit(1) + } + if err := writeJSONIfRequested(*unresolvedOut, res.Unresolved); err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + os.Exit(1) + } + + if *printY { + if _, err := os.Stdout.Write(res.YAML); err != nil { + fmt.Fprintf(os.Stderr, "write stdout: %v\n", err) + os.Exit(1) + } + return + } + + if err := os.WriteFile(opts.OutFile, res.YAML, 0o644); err != nil { + fmt.Fprintf(os.Stderr, "write %s: %v\n", opts.OutFile, err) + os.Exit(1) + } + + intentStr := "unknown" + targetFamilyStr := "unknown" + if res.Plan != nil { + intentStr = string(res.Plan.Intent) + targetFamilyStr = string(res.Plan.TargetFamily) + } + + fmt.Fprintf( + os.Stderr, + "wrote %s (detected=%s intent=%s target-family=%s)\n", + opts.OutFile, + res.DetectedType, + intentStr, + targetFamilyStr, + ) +} + +// splitTrimmed splits a comma-separated string into a slice, trimming each +// element and dropping empty entries. +func splitTrimmed(s string) []string { + if strings.TrimSpace(s) == "" { + return nil + } + var out []string + for _, p := range strings.Split(s, ",") { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out +} + +func parseOptionalBool(s string) (*bool, error) { + switch strings.TrimSpace(strings.ToLower(s)) { + case "", "auto": + return nil, nil + case "1", "t", "true", "yes", "on": + v := true + return &v, nil + case "0", "f", "false", "no", "off": + v := false + return &v, nil + default: + return nil, fmt.Errorf("expected auto|true|false") + } +} + +func writeJSONIfRequested(path string, v any) error { + path = strings.TrimSpace(path) + if path == "" || v == nil { + return nil + } + + b, err := json.MarshalIndent(v, "", " ") + if err != nil { + return fmt.Errorf("marshal %s: %w", path, err) + } + if err := os.WriteFile(path, b, 0o644); err != nil { + return fmt.Errorf("write %s: %w", path, err) + } + return nil +} + +func failOnHighSeverityUnresolved(items []specgen.UnresolvedItem) error { + var high []string + for _, item := range items { + if strings.EqualFold(strings.TrimSpace(item.Severity), "high") { + msg := strings.TrimSpace(item.Message) + if msg == "" { + msg = item.Code + } + if msg == "" { + msg = "unspecified high-severity unresolved item" + } + high = append(high, msg) + } + } + if len(high) == 0 { + return nil + } + return errors.New(strings.Join(high, "; ")) +} diff --git a/internal/specgen/adapters.go b/internal/specgen/adapters.go new file mode 100644 index 000000000..0b81a932c --- /dev/null +++ b/internal/specgen/adapters.go @@ -0,0 +1,160 @@ +package specgen + +import ( + "fmt" + "strings" + + "github.com/project-dalec/dalec" +) + +type baselineAdapter interface { + ID() string + Supports(f *RepoFacts, plan *SpecPlan) bool + ConfigureSource(spec *dalec.Spec, a *Analysis, plan *SpecPlan, warnings *[]string) + EmitBuild(spec *dalec.Spec, a *Analysis, plan *SpecPlan, warnings *[]string) (map[string]string, string) +} + +func selectBaselineAdapter(f *RepoFacts, plan *SpecPlan) baselineAdapter { + style := strings.TrimSpace(planBuildStyle(plan)) + if style != "" { + switch { + case strings.HasPrefix(style, "go"): + return goBaselineAdapter{} + case strings.HasPrefix(style, "rust"): + return rustBaselineAdapter{} + case strings.HasPrefix(style, "node"): + return nodeBaselineAdapter{} + case strings.HasPrefix(style, "python"): + return pythonBaselineAdapter{} + default: + return genericBaselineAdapter{} + } + } + + adapters := []baselineAdapter{ + goBaselineAdapter{}, + rustBaselineAdapter{}, + nodeBaselineAdapter{}, + pythonBaselineAdapter{}, + genericBaselineAdapter{}, + } + for _, adapter := range adapters { + if adapter.Supports(f, plan) { + return adapter + } + } + return genericBaselineAdapter{} +} + +type goBaselineAdapter struct{} +type rustBaselineAdapter struct{} +type nodeBaselineAdapter struct{} +type pythonBaselineAdapter struct{} +type genericBaselineAdapter struct{} + +func (goBaselineAdapter) ID() string { return "go" } +func (rustBaselineAdapter) ID() string { return "rust" } +func (nodeBaselineAdapter) ID() string { return "node" } +func (pythonBaselineAdapter) ID() string { return "python" } +func (genericBaselineAdapter) ID() string { return "generic" } + +func (goBaselineAdapter) Supports(f *RepoFacts, plan *SpecPlan) bool { + return f != nil && (f.PrimaryType == "go" || strings.HasPrefix(strings.TrimSpace(planBuildStyle(plan)), "go")) +} +func (rustBaselineAdapter) Supports(f *RepoFacts, plan *SpecPlan) bool { + return f != nil && (f.PrimaryType == "rust" || strings.HasPrefix(strings.TrimSpace(planBuildStyle(plan)), "rust")) +} +func (nodeBaselineAdapter) Supports(f *RepoFacts, plan *SpecPlan) bool { + return f != nil && (f.PrimaryType == "node" || strings.HasPrefix(strings.TrimSpace(planBuildStyle(plan)), "node")) +} +func (pythonBaselineAdapter) Supports(f *RepoFacts, plan *SpecPlan) bool { + return f != nil && (f.PrimaryType == "python" || strings.HasPrefix(strings.TrimSpace(planBuildStyle(plan)), "python")) +} +func (genericBaselineAdapter) Supports(f *RepoFacts, plan *SpecPlan) bool { return true } + +func (goBaselineAdapter) ConfigureSource(spec *dalec.Spec, a *Analysis, plan *SpecPlan, warnings *[]string) { + configureSuggestedSourceGenerator(spec, a, warnings) +} +func (rustBaselineAdapter) ConfigureSource(spec *dalec.Spec, a *Analysis, plan *SpecPlan, warnings *[]string) { + configureSuggestedSourceGenerator(spec, a, warnings) +} +func (nodeBaselineAdapter) ConfigureSource(spec *dalec.Spec, a *Analysis, plan *SpecPlan, warnings *[]string) { + configureSuggestedSourceGenerator(spec, a, warnings) +} +func (pythonBaselineAdapter) ConfigureSource(spec *dalec.Spec, a *Analysis, plan *SpecPlan, warnings *[]string) { + configureSuggestedSourceGenerator(spec, a, warnings) +} +func (genericBaselineAdapter) ConfigureSource(spec *dalec.Spec, a *Analysis, plan *SpecPlan, warnings *[]string) { + if spec == nil { + return + } + src := spec.Sources["src"] + src.Generate = nil + spec.Sources["src"] = src +} + +func (goBaselineAdapter) EmitBuild(spec *dalec.Spec, a *Analysis, plan *SpecPlan, warnings *[]string) (map[string]string, string) { + env := map[string]string{} + return env, buildGoByPlan(spec, a, plan, warnings, env) +} +func (rustBaselineAdapter) EmitBuild(spec *dalec.Spec, a *Analysis, plan *SpecPlan, warnings *[]string) (map[string]string, string) { + env := map[string]string{} + return env, buildRustByPlan(spec, a, plan, warnings, env) +} +func (nodeBaselineAdapter) EmitBuild(spec *dalec.Spec, a *Analysis, plan *SpecPlan, warnings *[]string) (map[string]string, string) { + env := map[string]string{} + return env, buildNodeByPlan(spec, a, plan, warnings, env) +} +func (pythonBaselineAdapter) EmitBuild(spec *dalec.Spec, a *Analysis, plan *SpecPlan, warnings *[]string) (map[string]string, string) { + env := map[string]string{} + return env, buildPythonByPlan(spec, a, plan, warnings, env) +} +func (genericBaselineAdapter) EmitBuild(spec *dalec.Spec, a *Analysis, plan *SpecPlan, warnings *[]string) (map[string]string, string) { + return map[string]string{}, genericBuildCommand(analysisFacts(a)) +} + +func configureSuggestedSourceGenerator(spec *dalec.Spec, a *Analysis, warnings *[]string) { + if spec == nil { + return + } + + src := spec.Sources["src"] + src.Generate = nil + + f := analysisFacts(a) + if f == nil { + spec.Sources["src"] = src + return + } + + switch strings.TrimSpace(f.SuggestedSourceGenerator) { + case "gomod": + if f.SuggestedSourceGeneratorSafe { + src.Generate = []*dalec.SourceGenerator{{Gomod: &dalec.GeneratorGomod{}}} + } else if strings.TrimSpace(f.SuggestedSourceGeneratorReason) != "" { + *warnings = append(*warnings, fmt.Sprintf("skipping optional gomod source generator: %s", strings.TrimSpace(f.SuggestedSourceGeneratorReason))) + } + case "cargohome": + src.Generate = []*dalec.SourceGenerator{{Cargohome: &dalec.GeneratorCargohome{}}} + case "node-mod": + src.Generate = []*dalec.SourceGenerator{{NodeMod: &dalec.GeneratorNodeMod{}}} + case "pip": + src.Generate = []*dalec.SourceGenerator{{Pip: &dalec.GeneratorPip{}}} + } + + spec.Sources["src"] = src +} + +func planBuildStyle(plan *SpecPlan) string { + if plan == nil { + return "" + } + return strings.TrimSpace(plan.BuildStyle) +} + +func analysisFacts(a *Analysis) *RepoFacts { + if a == nil { + return nil + } + return a.Facts +} diff --git a/internal/specgen/analysis.go b/internal/specgen/analysis.go new file mode 100644 index 000000000..ba2122a20 --- /dev/null +++ b/internal/specgen/analysis.go @@ -0,0 +1,1641 @@ +package specgen + +import ( + "os" + "path/filepath" + "sort" + "strings" +) + +func AnalyzeRepo(f *RepoFacts) (*Analysis, []string) { + var warnings []string + if f == nil { + a := &Analysis{ + SelectedStrategy: "generic-placeholder", + SelectedRuntimeKind: "unknown", + Confidence: ConfidenceReport{ + Metadata: "low", + Strategy: "low", + Runtime: "low", + Overall: "low", + }, + Unresolved: []UnresolvedItem{ + { + Code: "analysis.nil_facts", + Message: "repo facts were nil; falling back to generic placeholder strategy", + Severity: "high", + }, + }, + Alternatives: &Alternatives{ + BuildStyles: []ScoredChoice{ + { + Value: "generic-placeholder", + Score: 10, + Confidence: "low", + Reason: "repo facts were nil", + }, + }, + }, + Decisions: []DecisionRecord{ + { + Field: "build_style", + Chosen: "generic-placeholder", + Confidence: "low", + Reason: "repo facts were nil", + }, + }, + } + return a, warnings + } + + a := &Analysis{ + Facts: f, + Metadata: MetadataHint{ + Name: f.Name, + Description: f.Description, + Website: f.Website, + License: f.License, + Version: f.Version, + }, + SelectedRuntimeKind: "unknown", + } + + addUnique := func(dst *[]string, vals ...string) { + seen := map[string]struct{}{} + for _, x := range *dst { + seen[x] = struct{}{} + } + for _, v := range vals { + v = strings.TrimSpace(v) + if v == "" { + continue + } + if _, ok := seen[v]; ok { + continue + } + seen[v] = struct{}{} + *dst = append(*dst, v) + } + } + + addEvidence := func(kind, source, detail, confidence string) { + a.Evidence = append(a.Evidence, Evidence{ + Kind: kind, + Source: source, + Detail: detail, + Confidence: normalizeConfidence(confidence), + }) + } + + if f.HasGoMod { + addUnique(&a.Languages, "go") + addUnique(&a.BuildDrivers, "go") + addEvidence("manifest", "go.mod", "Go module detected", "high") + if !f.SuggestedSourceGeneratorSafe && strings.TrimSpace(f.SuggestedSourceGeneratorReason) != "" { + addEvidence("generator", "go.mod", "optional gomod source generator skipped: "+strings.TrimSpace(f.SuggestedSourceGeneratorReason), "low") + } + } + if f.HasCargoToml { + addUnique(&a.Languages, "rust") + addUnique(&a.BuildDrivers, "cargo") + addEvidence("manifest", "Cargo.toml", "Cargo manifest detected", "high") + } + if f.HasPackageJSON { + addUnique(&a.Languages, "node") + switch f.NodePackageManager { + case "pnpm": + addUnique(&a.BuildDrivers, "pnpm") + case "yarn": + addUnique(&a.BuildDrivers, "yarn") + default: + addUnique(&a.BuildDrivers, "npm") + } + addEvidence("manifest", "package.json", "Node package manifest detected", "high") + } + if f.HasPyProject || f.HasRequirements || f.HasSetupPy { + addUnique(&a.Languages, "python") + addUnique(&a.BuildDrivers, "pip") + addEvidence("manifest", "python", "Python build metadata detected", "medium") + } + if f.HasMakefile { + addUnique(&a.BuildDrivers, "make") + addEvidence("buildfile", "Makefile", "Top-level Makefile detected", "high") + } + if f.HasDockerfile { + addUnique(&a.PackageHints, "dockerfile") + addEvidence("buildfile", "Dockerfile", "Container build file detected", "medium") + } + if f.HasContainerfile { + addUnique(&a.PackageHints, "containerfile") + addEvidence("buildfile", "Containerfile", "Container build file detected", "medium") + } + if fileExists(filepath.Join(f.RepoDir, "azure-pipelines.yml")) { + addUnique(&a.PackageHints, "azure-pipelines") + addEvidence("ci", "azure-pipelines.yml", "Azure Pipelines file detected", "medium") + } + if hasDir(filepath.Join(f.RepoDir, ".github", "workflows")) { + addUnique(&a.PackageHints, "github-actions") + addEvidence("ci", ".github/workflows", "GitHub Actions workflow directory detected", "medium") + } + + // Discover richer signals before strategy/runtime/artifact selection. + a.CandidateComponents = discoverCandidateComponents(f) + a.Services = discoverServices(f) + a.ManpagePaths = discoverManpages(f) + a.ConfigPaths = discoverConfigPaths(f) + a.CICommands = discoverCICommands(f) + a.InstallHints = discoverInstallHints(f) + a.BuildHints, a.TestHints, a.InstallHints2, a.DocHints, a.MakeTargets, a.Components = deriveStructuredHints(f, a) + + // Normalize legacy string install hints from structured hints. + if len(a.InstallHints2) > 0 { + a.InstallHints = commandHintsToStrings(a.InstallHints2) + } + + a.RepoShape = inferRepoShape(f) + a.SelectedStrategy = selectStrategy(f, a) + a.Runtime = inferRuntime(f, a) + a.SelectedRuntimeKind = a.Runtime.Kind + + a.InstallLayout = deriveInstallLayout(f, a) + a.Artifacts = inferArtifacts(f, a) + a.Alternatives = &Alternatives{ + Components: scoreComponentChoices(f, a), + BuildStyles: scoreBuildStyleChoices(f, a), + EntryPoints: scoreEntrypointChoices(f, a), + PackageNames: scorePackageNameChoices(f, a), + } + + a.Decisions = deriveDecisionRecords(f, a) + a.Confidence = inferConfidence(f, a) + + a.Unresolved = deriveAnalysisUnresolved(f, a) + if len(a.Unresolved) > 0 { + warnings = append(warnings, "analysis found unresolved areas; baseline preserved structured uncertainty for refinement") + } + + return a, warnings +} + +func deriveStructuredHints(f *RepoFacts, a *Analysis) ([]CommandHint, []CommandHint, []CommandHint, []CommandHint, []MakeTargetHint, []ComponentHint) { + var buildHints []CommandHint + var testHints []CommandHint + var installHints []CommandHint + var docHints []CommandHint + var makeTargets []MakeTargetHint + var components []ComponentHint + + if f != nil { + buildHints = append(buildHints, f.BuildHints...) + testHints = append(testHints, f.TestHints...) + installHints = append(installHints, f.InstallHints2...) + docHints = append(docHints, f.DocHints...) + makeTargets = append(makeTargets, f.MakeTargets...) + components = append(components, f.Components...) + } + + // Backfill from legacy string hints only if structured hints are sparse. + if len(buildHints) == 0 && a != nil { + for _, line := range a.CICommands { + switch classifyAnalysisCommandLine(line) { + case "test": + testHints = append(testHints, CommandHint{ + Command: line, + Source: "analysis.ci", + Kind: "test", + Confidence: "low", + Reason: "derived from CI command line", + }) + case "install": + installHints = append(installHints, CommandHint{ + Command: line, + Source: "analysis.ci", + Kind: "install", + Confidence: "low", + Reason: "derived from CI command line", + }) + case "doc": + docHints = append(docHints, CommandHint{ + Command: line, + Source: "analysis.ci", + Kind: "doc", + Confidence: "low", + Reason: "derived from CI command line", + }) + case "build", "release": + buildHints = append(buildHints, CommandHint{ + Command: line, + Source: "analysis.ci", + Kind: "build", + Confidence: "low", + Reason: "derived from CI command line", + }) + } + } + } + + if len(installHints) == 0 && a != nil { + for _, line := range a.InstallHints { + installHints = append(installHints, CommandHint{ + Command: line, + Source: "analysis.install", + Kind: "install", + Confidence: "low", + Reason: "derived from install-like command line", + }) + } + } + + if len(components) == 0 && a != nil { + for _, name := range a.CandidateComponents { + components = append(components, ComponentHint{ + Name: sanitizeName(name), + Role: "cli", + Confidence: "low", + Reason: "candidate component fallback", + }) + } + for _, s := range a.Services { + components = append(components, ComponentHint{ + Name: sanitizeName(s.Name), + Path: s.UnitPath, + Role: "daemon", + Confidence: "medium", + Reason: "service hint fallback", + }) + } + } + + return dedupeCommandHints(buildHints), + dedupeCommandHints(testHints), + dedupeCommandHints(installHints), + dedupeCommandHints(docHints), + dedupeMakeTargetHints(makeTargets), + dedupeComponentHints(components) +} + +func classifyAnalysisCommandLine(line string) string { + low := strings.ToLower(strings.TrimSpace(line)) + switch { + case strings.HasPrefix(low, "go test"), + strings.HasPrefix(low, "cargo test"), + strings.HasPrefix(low, "pytest"), + strings.Contains(low, "make test"): + return "test" + case strings.Contains(low, "install "), + strings.Contains(low, "mkdir -p"), + strings.Contains(low, "cp "), + strings.Contains(low, "make install"): + return "install" + case strings.Contains(low, "doc"), + strings.Contains(low, "man"), + strings.Contains(low, "md2man"): + return "doc" + case strings.Contains(low, "release"): + return "release" + case strings.HasPrefix(low, "go build"), + strings.HasPrefix(low, "cargo build"), + strings.HasPrefix(low, "npm "), + strings.HasPrefix(low, "pnpm "), + strings.HasPrefix(low, "yarn "), + strings.HasPrefix(low, "make"): + return "build" + default: + return "" + } +} + +func inferRepoShape(f *RepoFacts) RepoShape { + shape := RepoShape{ + PrimarySubdir: ".", + } + if f.GoMainRel != "" { + shape.PrimarySubdir = f.GoMainRel + } + if len(f.GoMainCandidates) > 0 { + shape.CandidateSubdirs = append([]string(nil), f.GoMainCandidates...) + } + if len(f.GoMainCandidates) > 1 { + shape.HasMultipleBins = true + shape.HasMultipleApps = true + } + if cargoWorkspace(f.RepoDir) || nodeWorkspace(f.RepoDir) { + shape.HasWorkspace = true + } + if shape.HasWorkspace || len(nonEmptyCount( + boolToString(f.HasGoMod), + boolToString(f.HasCargoToml), + boolToString(f.HasPackageJSON), + boolToString(f.HasPyProject || f.HasRequirements || f.HasSetupPy), + )) > 1 || shape.HasMultipleApps { + shape.IsMonorepo = true + } + return shape +} + +func inferRuntime(f *RepoFacts, a *Analysis) RuntimeHint { + if a != nil && len(a.Services) > 0 { + entry := sanitizeName(a.Services[0].Name) + if entry == "" || entry == "unknown" { + entry = sanitizeName(f.Name) + } + return RuntimeHint{ + Kind: "daemon", + Entrypoint: entry, + Cmd: "", + Confidence: "medium", + } + } + + if a != nil { + for _, c := range a.Components { + if c.Role == "daemon" { + return RuntimeHint{ + Kind: "daemon", + Entrypoint: sanitizeName(c.Name), + Confidence: normalizeConfidence(c.Confidence), + } + } + } + for _, c := range a.Components { + if c.Role == "cli" { + cmd := "" + if f != nil && (f.PrimaryType == "go" || f.PrimaryType == "rust") { + cmd = "--help" + } + return RuntimeHint{ + Kind: "cli", + Entrypoint: sanitizeName(c.Name), + Cmd: cmd, + Confidence: normalizeConfidence(c.Confidence), + } + } + } + } + + switch f.PrimaryType { + case "go": + entry := sanitizeName(f.Name) + if entry == "" || entry == "unknown" { + entry = "app" + } + return RuntimeHint{ + Kind: "cli", + Entrypoint: entry, + Cmd: "--help", + Confidence: "medium", + } + case "rust": + entry := sanitizeName(f.Name) + if f.CargoBinName != "" { + entry = sanitizeName(f.CargoBinName) + } + return RuntimeHint{ + Kind: "cli", + Entrypoint: entry, + Cmd: "--help", + Confidence: "medium", + } + case "node": + switch { + case f.NodeMain != "": + return RuntimeHint{ + Kind: "cli", + Entrypoint: "node", + Cmd: f.NodeMain, + Confidence: "medium", + } + case f.NodeBinName != "": + return RuntimeHint{ + Kind: "cli", + Entrypoint: f.NodeBinName, + Confidence: "medium", + } + default: + return RuntimeHint{ + Kind: "unknown", + Confidence: "low", + } + } + case "python": + switch { + case f.PythonConsoleScript != "": + return RuntimeHint{ + Kind: "cli", + Entrypoint: f.PythonConsoleScript, + Confidence: "high", + } + case f.PythonModuleName != "": + return RuntimeHint{ + Kind: "cli", + Entrypoint: "python3", + Cmd: "-m " + f.PythonModuleName, + Confidence: "medium", + } + default: + return RuntimeHint{ + Kind: "unknown", + Confidence: "low", + } + } + default: + return RuntimeHint{ + Kind: "unknown", + Confidence: "low", + } + } +} + +func inferArtifacts(f *RepoFacts, a *Analysis) []ArtifactHint { + var out []ArtifactHint + + addArtifact := func(kind, name, path, confidence, reason string) { + out = append(out, ArtifactHint{ + Kind: kind, + Name: name, + Path: path, + Confidence: normalizeConfidence(confidence), + Reason: strings.TrimSpace(reason), + }) + } + + if a != nil { + for _, pe := range a.InstallLayout.Binaries { + name := sanitizeName(filepath.Base(pe.Path)) + if name == "" || name == "unknown" { + name = sanitizeName(a.Runtime.Entrypoint) + } + addArtifact("binary", name, pe.Path, pe.Confidence, pe.Reason) + } + for _, pe := range a.InstallLayout.Manpages { + addArtifact("manpage", filepath.Base(pe.Path), pe.Path, pe.Confidence, pe.Reason) + } + for _, pe := range a.InstallLayout.ConfigFiles { + addArtifact("config", filepath.Base(pe.Path), pe.Path, pe.Confidence, pe.Reason) + } + for _, pe := range a.InstallLayout.Docs { + addArtifact("doc", filepath.Base(pe.Path), pe.Path, pe.Confidence, pe.Reason) + } + for _, pe := range a.InstallLayout.DataDirs { + addArtifact("data", filepath.Base(pe.Path), pe.Path, pe.Confidence, pe.Reason) + } + for _, pe := range a.InstallLayout.Libexec { + addArtifact("libexec", filepath.Base(pe.Path), pe.Path, pe.Confidence, pe.Reason) + } + } + + // Fallback artifacts when install-layout evidence is still sparse. + switch f.PrimaryType { + case "go": + if !hasArtifactKind(out, "binary") { + name := sanitizeName(f.Name) + if a != nil && strings.TrimSpace(a.Runtime.Entrypoint) != "" && a.Runtime.Entrypoint != "node" && a.Runtime.Entrypoint != "python3" { + name = sanitizeName(a.Runtime.Entrypoint) + } + addArtifact("binary", name, nativeBinaryArtifactPath(name), "medium", "fallback Go binary artifact path") + } + case "rust": + if !hasArtifactKind(out, "binary") { + name := sanitizeName(f.Name) + if f.CargoBinName != "" { + name = sanitizeName(f.CargoBinName) + } + addArtifact("binary", name, "src/target/release/"+name, "medium", "fallback Cargo release artifact path") + } + case "node": + if !hasArtifactKind(out, "doc") { + addArtifact("doc", "README", "src/README.md", "low", "fallback Node documentation artifact") + } + case "python": + if !hasArtifactKind(out, "wheel") { + addArtifact("wheel", sanitizeName(f.Name), "src/dist", "medium", "fallback Python wheel/dist artifact") + } + default: + if len(out) == 0 { + addArtifact("unknown", "", "", "low", "no strong artifact evidence found") + } + } + + return dedupeArtifactHints(out) +} + +func inferConfidence(f *RepoFacts, a *Analysis) ConfidenceReport { + c := ConfidenceReport{ + Metadata: "medium", + Strategy: "medium", + Runtime: normalizeConfidence(a.Runtime.Confidence), + Overall: "medium", + } + + if looksTodo(a.Metadata.Description) || looksTodo(a.Metadata.License) { + c.Metadata = "low" + } + if a.Metadata.Website != "" && !looksTodo(a.Metadata.Description) && !looksTodo(a.Metadata.License) { + c.Metadata = "high" + } + + switch a.SelectedStrategy { + case "go-simple", "rust-simple": + c.Strategy = "high" + case "go-make", "node-npm-app", "node-yarn-app", "node-pnpm-app", "python-wheel", "python-requirements", "rust-workspace", "go-multi-bin", "go-make-multi-bin": + c.Strategy = "medium" + default: + c.Strategy = "low" + } + + if len(a.Alternatives.BuildStyles) > 0 { + c.Strategy = normalizeConfidence(a.Alternatives.BuildStyles[0].Confidence) + } + + if f.PrimaryType == "unknown" || a.SelectedStrategy == "generic-placeholder" { + c.Overall = "low" + return c + } + if c.Metadata == "high" && c.Strategy == "high" && c.Runtime != "low" { + c.Overall = "high" + return c + } + if c.Metadata == "low" || c.Strategy == "low" || c.Runtime == "low" { + c.Overall = "low" + return c + } + c.Overall = "medium" + return c +} + +func deriveAnalysisUnresolved(f *RepoFacts, a *Analysis) []UnresolvedItem { + var out []UnresolvedItem + + if f.PrimaryType == "unknown" { + out = append(out, UnresolvedItem{ + Code: "repo.unknown_type", + Message: "could not confidently classify the repository type", + Severity: "high", + Suggestions: []string{ + "inspect build/test scripts and CI files", + "preserve the baseline bundle so refinement can use richer repository context", + }, + }) + } + + if f.PrimaryType == "go" && f.GoNeedsManagedToolchain { + if strings.TrimSpace(f.GoManagedToolchainVersion) == "" { + out = append(out, UnresolvedItem{ + Code: "build.go_toolchain_version_unknown", + Message: "repo requires an explicit Go toolchain, but the required version could not be derived from go.mod", + Severity: "high", + Suggestions: []string{ + "inspect go.mod go/toolchain directives", + "set an explicit managed Go version in planning", + }, + }) + } else { + out = append(out, UnresolvedItem{ + Code: "build.go_toolchain_managed", + Message: "repo requires an explicit Go toolchain; baseline will select it automatically", + Severity: "low", + Suggestions: []string{ + "emit a managed Go toolchain in the deterministic build plan", + }, + }) + } +} + + + + if f.PrimaryType == "go" && f.HasGoMod && !f.SuggestedSourceGeneratorSafe && strings.TrimSpace(f.SuggestedSourceGeneratorReason) != "" { + out = append(out, UnresolvedItem{ + Code: "sources.generate.gomod_skipped", + Message: "baseline skipped the optional gomod source generator: " + strings.TrimSpace(f.SuggestedSourceGeneratorReason), + Severity: "low", + Suggestions: []string{ + "keep the baseline on direct go build for portability", + "let later refinement restore gomod prefetch only when the repo shape and toolchain are verified", + }, + }) + } + + if f.HasMakefile && strings.Contains(a.SelectedStrategy, "make") { + out = append(out, UnresolvedItem{ + Code: "artifacts.make_output_unknown", + Message: "make-driven build output may still be repo-specific even though the baseline uses normalized artifact paths", + Severity: "medium", + Suggestions: []string{ + "inspect Makefile targets and CI files to determine repo-specific output artifact paths", + }, + }) + } + + if len(a.Languages) > 1 { + out = append(out, UnresolvedItem{ + Code: "repo.multiple_languages", + Message: "multiple language ecosystems were detected; primary package intent may be mixed", + Severity: "medium", + Suggestions: []string{ + "prefer CI/build scripts over manifest-first guesses", + "preserve alternative build styles and components for later refinement", + }, + }) + } + + if len(a.BuildDrivers) > 2 { + out = append(out, UnresolvedItem{ + Code: "build.multiple_drivers", + Message: "multiple build drivers were detected; the best baseline strategy may still need refinement", + Severity: "medium", + Suggestions: []string{ + "compare Makefile, CI files, and manifest scripts", + }, + }) + } + + if a.RepoShape.HasMultipleBins { + out = append(out, UnresolvedItem{ + Code: "artifacts.multi_bin_selection", + Message: "multiple binary entrypoints were detected; baseline preserved alternatives but may still need refinement", + Severity: "medium", + Suggestions: []string{ + "add additional binaries", + "choose the intended package target", + }, + }) + } + + if f.HasMakefile && strings.Contains(a.SelectedStrategy, "make") { + out = append(out, UnresolvedItem{ + Code: "dependencies.build", + Message: "make-driven baseline builds often need extra native/system dependencies beyond the obvious toolchain packages", + Severity: "medium", + Suggestions: []string{ + "infer extra build deps from Makefile, CI, manifest scripts, and install context", + }, + }) + } + + if len(a.InstallLayout.Binaries) == 0 && (f.PrimaryType == "go" || f.PrimaryType == "rust") { + out = append(out, UnresolvedItem{ + Code: "install_layout.binaries", + Message: "no strong install-layout binary evidence was found; baseline used fallback artifact paths", + Severity: "medium", + Suggestions: []string{ + "inspect install commands, Makefile targets, and release workflows", + }, + }) + } + + if a.Alternatives != nil && len(a.Alternatives.Components) > 1 { + if absInt(a.Alternatives.Components[0].Score-a.Alternatives.Components[1].Score) <= 10 { + out = append(out, UnresolvedItem{ + Code: "component.selection_close", + Message: "top component candidates are close in score", + Severity: "medium", + Suggestions: []string{ + "inspect CLI usage, service units, and install targets", + }, + }) + } + } + if a.Alternatives != nil && len(a.Alternatives.BuildStyles) > 1 { + if absInt(a.Alternatives.BuildStyles[0].Score-a.Alternatives.BuildStyles[1].Score) <= 10 { + out = append(out, UnresolvedItem{ + Code: "build_style.selection_close", + Message: "top build-style candidates are close in score", + Severity: "medium", + Suggestions: []string{ + "inspect CI, Makefile, and release scripts to choose the intended baseline build path", + }, + }) + } + } + + if looksTodo(a.Metadata.Description) { + out = append(out, UnresolvedItem{ + Code: "description", + Message: "description is missing or placeholder", + Severity: "low", + }) + } + if looksTodo(a.Metadata.License) { + out = append(out, UnresolvedItem{ + Code: "license", + Message: "license is missing or placeholder", + Severity: "medium", + }) + } + if strings.TrimSpace(a.Metadata.Website) == "" { + out = append(out, UnresolvedItem{ + Code: "website", + Message: "website/homepage is missing", + Severity: "low", + }) + } + + return dedupeUnresolved(out) +} + +func cargoWorkspace(repoDir string) bool { + return fileContains(filepath.Join(repoDir, "Cargo.toml"), "[workspace]") +} + +func nodeWorkspace(repoDir string) bool { + return fileContains(filepath.Join(repoDir, "package.json"), `"workspaces"`) +} + +func discoverCandidateComponents(f *RepoFacts) []string { + if f != nil && len(f.Components) > 0 { + var out []string + for _, c := range f.Components { + if s := sanitizeName(c.Name); s != "" && s != "unknown" { + out = append(out, s) + } + + if s := canonicalRepoName(c.Name); s != "" && !isLikelyBuildHelperBinary(s) { + out = append(out, s) + } + } + sort.Strings(out) + return dedupeStrings(out) + } + + var out []string + add := func(v string) { + v = sanitizeName(strings.TrimSpace(v)) + if v == "" || v == "unknown" { + return + } + for _, x := range out { + if x == v { + return + } + } + out = append(out, v) + } + + add(f.Name) + add(f.CargoBinName) + add(f.NodeBinName) + add(f.PythonConsoleScript) + + for _, rel := range f.GoMainCandidates { + rel = filepath.ToSlash(strings.TrimSpace(rel)) + if rel == "." || rel == "" { + add(f.Name) + continue + } + add(filepath.Base(rel)) + } + + sort.Strings(out) + return out +} + +func discoverServices(f *RepoFacts) []ServiceHint { + if f != nil && len(f.Services) > 0 { + return append([]ServiceHint(nil), f.Services...) + } + + var out []ServiceHint + paths := []string{ + "systemd", + "packaging/systemd", + "deploy/systemd", + "contrib/systemd", + } + for _, root := range paths { + full := filepath.Join(f.RepoDir, root) + entries, err := os.ReadDir(full) + if err != nil { + continue + } + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".service") { + continue + } + out = append(out, ServiceHint{ + Name: strings.TrimSuffix(e.Name(), ".service"), + UnitPath: filepath.ToSlash(filepath.Join(root, e.Name())), + Confidence: "medium", + Reason: "systemd service file discovered in repository", + }) + } + } + return dedupeServiceHints(out) +} + +func discoverManpages(f *RepoFacts) []string { + if f != nil && len(f.ManpagePaths) > 0 { + return append([]string(nil), f.ManpagePaths...) + } + + var out []string + roots := []string{"man", "doc/man", "share/man"} + for _, root := range roots { + full := filepath.Join(f.RepoDir, root) + _ = filepath.Walk(full, func(path string, info os.FileInfo, err error) error { + if err != nil || info == nil || info.IsDir() { + return nil + } + name := info.Name() + if strings.HasSuffix(name, ".1") || strings.HasSuffix(name, ".5") || strings.HasSuffix(name, ".8") { + if rel, err := filepath.Rel(f.RepoDir, path); err == nil { + out = append(out, filepath.ToSlash(rel)) + } + } + return nil + }) + } + sort.Strings(out) + return dedupeStrings(out) +} + +func discoverConfigPaths(f *RepoFacts) []string { + if f != nil && len(f.ConfigPaths) > 0 { + return append([]string(nil), f.ConfigPaths...) + } + + var out []string + roots := []string{"etc", "packaging", "dist", "contrib"} + + for _, root := range roots { + full := filepath.Join(f.RepoDir, root) + _ = filepath.Walk(full, func(path string, info os.FileInfo, err error) error { + if err != nil || info == nil || info.IsDir() { + return nil + } + + rel, err := filepath.Rel(f.RepoDir, path) + if err != nil { + return nil + } + rel = filepath.ToSlash(rel) + + if !looksInstallableConfigPath(rel) { + return nil + } + + out = append(out, rel) + return nil + }) + } + + sort.Strings(out) + return dedupeStrings(out) +} + +func looksInstallableConfigPath(rel string) bool { + low := strings.ToLower(strings.TrimSpace(filepath.ToSlash(rel))) + if low == "" { + return false + } + + if strings.HasPrefix(low, "config/") || + strings.HasPrefix(low, "configs/") || + strings.HasPrefix(low, "deploy/") || + strings.Contains(low, "/samples/") || + strings.Contains(low, "/crd/") || + strings.Contains(low, "/grafana/") || + strings.Contains(low, "kustomization.yaml") { + return false + } + + if strings.HasPrefix(low, "etc/") || + strings.Contains(low, "/etc/") || + strings.HasPrefix(low, "packaging/") { + return strings.HasSuffix(low, ".conf") || + strings.HasSuffix(low, ".ini") || + strings.HasSuffix(low, ".toml") || + strings.HasSuffix(low, ".yaml") || + strings.HasSuffix(low, ".yml") || + strings.HasSuffix(low, ".json") + } + + return strings.HasSuffix(low, ".conf") || + strings.HasSuffix(low, ".ini") || + strings.HasSuffix(low, ".toml") +} + +func discoverCICommands(f *RepoFacts) []string { + var out []string + + if f != nil { + for _, h := range f.BuildHints { + out = append(out, h.Command) + } + for _, h := range f.TestHints { + out = append(out, h.Command) + } + for _, h := range f.DocHints { + out = append(out, h.Command) + } + if len(out) > 0 { + return dedupeStrings(out) + } + } + + candidates := []string{ + ".github/workflows", + "azure-pipelines.yml", + ".gitlab-ci.yml", + } + for _, c := range candidates { + full := filepath.Join(f.RepoDir, c) + st, err := os.Stat(full) + if err != nil { + continue + } + if st.IsDir() { + files, err := os.ReadDir(full) + if err != nil { + continue + } + for _, file := range files { + if file.IsDir() { + continue + } + b, err := os.ReadFile(filepath.Join(full, file.Name())) + if err != nil { + continue + } + out = append(out, extractCommandLikeLines(string(b))...) + } + continue + } + b, err := os.ReadFile(full) + if err != nil { + continue + } + out = append(out, extractCommandLikeLines(string(b))...) + } + return dedupeStrings(out) +} + +func discoverInstallHints(f *RepoFacts) []string { + if f != nil && len(f.InstallHints2) > 0 { + return commandHintsToStrings(f.InstallHints2) + } + + var out []string + for _, name := range []string{"Makefile", "GNUmakefile", "makefile"} { + b, err := os.ReadFile(filepath.Join(f.RepoDir, name)) + if err != nil { + continue + } + out = append(out, extractInstallLikeLines(string(b))...) + } + return dedupeStrings(out) +} + +func deriveInstallLayout(f *RepoFacts, a *Analysis) InstallLayout { + var layout InstallLayout + + add := func(dst *[]PathEvidence, pe PathEvidence) { + pe.Path = filepath.ToSlash(strings.TrimSpace(pe.Path)) + pe.Kind = strings.TrimSpace(pe.Kind) + pe.Source = strings.TrimSpace(pe.Source) + pe.Confidence = normalizeConfidence(pe.Confidence) + pe.Reason = strings.TrimSpace(pe.Reason) + if pe.Path == "" || pe.Kind == "" { + return + } + for _, cur := range *dst { + if cur.Path == pe.Path && cur.Kind == pe.Kind { + return + } + } + *dst = append(*dst, pe) + } + + // Binary candidates inferred from components/runtime. + componentNames := dedupeStrings(append([]string(nil), a.CandidateComponents...)) + if a.Runtime.Entrypoint != "" && a.Runtime.Entrypoint != "node" && a.Runtime.Entrypoint != "python3" { + componentNames = dedupeStrings(append(componentNames, sanitizeName(a.Runtime.Entrypoint))) + } + for _, c := range a.Components { + if name := sanitizeName(c.Name); name != "" && name != "unknown" { + componentNames = dedupeStrings(append(componentNames, name)) + } + } + for _, name := range componentNames { + switch f.PrimaryType { + case "go": + add(&layout.Binaries, PathEvidence{ + Path: nativeBinaryArtifactPath(name), + Kind: "binary", + Source: "analysis.component", + Confidence: "medium", + Reason: "normalized Go binary artifact path derived from selected/candidate component", + }) + case "rust": + add(&layout.Binaries, PathEvidence{ + Path: "src/target/release/" + name, + Kind: "binary", + Source: "analysis.component", + Confidence: "medium", + Reason: "normalized Cargo binary artifact path derived from selected/candidate component", + }) + } + } + + for _, mp := range a.ManpagePaths { + add(&layout.Manpages, PathEvidence{ + Path: mp, + Kind: "manpage", + Source: "analysis.manpage", + Confidence: "medium", + Reason: "manpage discovered in repository", + }) + } + for _, cp := range a.ConfigPaths { + add(&layout.ConfigFiles, PathEvidence{ + Path: cp, + Kind: "config", + Source: "analysis.config", + Confidence: "medium", + Reason: "config-like file discovered in repository", + }) + } + for _, s := range a.Services { + if strings.TrimSpace(s.UnitPath) != "" { + add(&layout.Systemd, PathEvidence{ + Path: s.UnitPath, + Kind: "systemd", + Source: "analysis.service", + Confidence: normalizeConfidence(s.Confidence), + Reason: defaultString(s.Reason, "service unit discovered in repository"), + }) + } + if strings.TrimSpace(s.ConfigPath) != "" { + add(&layout.ConfigFiles, PathEvidence{ + Path: s.ConfigPath, + Kind: "config", + Source: "analysis.service", + Confidence: normalizeConfidence(s.Confidence), + Reason: "service-associated config path discovered in repository", + }) + } + } + + // Only add README as a doc artifact for package types that install docs + if a.SelectedStrategy != "generic-placeholder" && !looksContainerOnlyAnalysis(a) { + for _, doc := range []string{"README.md", "README"} { + full := filepath.Join(f.RepoDir, doc) + if fileExists(full) { + add(&layout.Docs, PathEvidence{ + Path: filepath.ToSlash(doc), + Kind: "doc", + Source: "analysis.doc", + Confidence: "medium", + Reason: "documentation path discovered in repository", + }) + break + } + } + } + + for _, dir := range []string{"data", "assets", "share"} { + if hasDir(filepath.Join(f.RepoDir, dir)) { + add(&layout.DataDirs, PathEvidence{ + Path: filepath.ToSlash(dir), + Kind: "data_dir", + Source: "analysis.data", + Confidence: "low", + Reason: "data-like directory discovered in repository", + }) + } + } + + return layout +} + +func scoreComponentChoices(f *RepoFacts, a *Analysis) []ScoredChoice { + type acc struct { + score int + evidence []string + reason string + } + + m := map[string]*acc{} + add := func(name string, score int, evidence, reason string) { + name = sanitizeName(name) + if name == "" || name == "unknown" { + return + } + cur, ok := m[name] + if !ok { + cur = &acc{} + m[name] = cur + } + cur.score += score + if strings.TrimSpace(evidence) != "" { + cur.evidence = append(cur.evidence, evidence) + } + if cur.reason == "" && strings.TrimSpace(reason) != "" { + cur.reason = reason + } + } + + add(f.Name, 20, "repo name", "repository name is a candidate package/component name") + for _, name := range a.CandidateComponents { + add(name, 35, "candidate component", "component candidate discovered from repo structure") + } + for _, c := range a.Components { + score := 35 + if c.Role == "cli" { + score = 50 + } + if c.Role == "daemon" { + score = 55 + } + add(c.Name, score, defaultString(c.Path, "component hint"), defaultString(c.Reason, "structured component hint")) + } + for _, s := range a.Services { + add(s.Name, 60, defaultString(s.UnitPath, "service unit"), defaultString(s.Reason, "service unit strongly suggests runtime component")) + } + for _, mt := range a.MakeTargets { + name := sanitizeName(mt.Name) + if name == "" || name == "install" || name == "build" || name == "all" || name == "test" { + continue + } + add(name, 15, "make target "+mt.Name, defaultString(mt.Reason, "named Make target may map to a component")) + } + for _, cmd := range append(append([]CommandHint(nil), a.BuildHints...), a.InstallHints2...) { + low := strings.ToLower(cmd.Command) + for _, name := range a.CandidateComponents { + if strings.Contains(low, name) { + add(name, 20, defaultString(cmd.Source, "build/install command"), "component name appears in build/install command") + } + } + } + + if preferred := preferredGoPrimaryComponent(f, a); preferred != "" { + add(preferred, 40, "primary Go binary heuristic", "multi-binary Go repo primary-binary heuristic") +} +for _, c := range a.CandidateComponents { + if isAuxiliaryGoBinary(c) { + add(c, -20, "auxiliary binary heuristic", "likely helper or secondary binary for package baselines") + } +} + + var out []ScoredChoice + for name, cur := range m { + out = append(out, ScoredChoice{ + Value: name, + Score: cur.score, + Confidence: inferConfidenceFromScore(cur.score), + Reason: cur.reason, + Evidence: dedupeStrings(cur.evidence), + }) + } + sortScoredChoices(out) + return out +} + +func scoreBuildStyleChoices(f *RepoFacts, a *Analysis) []ScoredChoice { + type acc struct { + score int + evidence []string + reason string + } + + m := map[string]*acc{} + add := func(name string, score int, evidence, reason string) { + name = strings.TrimSpace(name) + if name == "" { + return + } + cur, ok := m[name] + if !ok { + cur = &acc{} + m[name] = cur + } + cur.score += score + if strings.TrimSpace(evidence) != "" { + cur.evidence = append(cur.evidence, evidence) + } + if cur.reason == "" && strings.TrimSpace(reason) != "" { + cur.reason = reason + } + } + + switch f.PrimaryType { + case "go": + if f.HasMakefile { + add("go-make", 60, "Makefile", "Go repo with Makefile strongly suggests make-driven build") + if len(f.GoMainCandidates) > 1 || a.RepoShape.HasMultipleBins { + add("go-make-multi-bin", 70, "Makefile + multi-bin", "Make-driven Go repository with multiple binaries") + } + } + if len(f.GoMainCandidates) > 1 || a.RepoShape.HasMultipleBins { + add("go-multi-bin", 55, "multiple Go main packages", "multiple Go main packages discovered") + } + add("go-simple", 45, "go.mod", "single-module Go repository") + case "rust": + if cargoWorkspace(f.RepoDir) || a.RepoShape.HasWorkspace { + add("rust-workspace", 65, "Cargo workspace", "workspace Cargo repository") + } + add("rust-simple", 45, "Cargo.toml", "Cargo repository") + case "node": + switch f.NodePackageManager { + case "pnpm": + add("node-pnpm-app", 60, "pnpm lock/metadata", "Node repository uses pnpm") + case "yarn": + add("node-yarn-app", 60, "yarn lock/metadata", "Node repository uses yarn") + default: + add("node-npm-app", 60, "package-lock/npm metadata", "Node repository uses npm") + } + case "python": + if f.HasPyProject { + add("python-wheel", 60, "pyproject.toml", "pyproject-based Python package") + } + if f.HasRequirements || f.HasSetupPy { + add("python-requirements", 45, "requirements/setup.py", "requirements/setup.py-based Python packaging") + } + default: + add("generic-placeholder", 10, "unknown repo type", "no strong language-specific baseline build style") + } + + for _, h := range a.BuildHints { + low := strings.ToLower(h.Command) + switch { + case strings.Contains(low, "make"): + add("go-make", 15, defaultString(h.Source, "build hint"), "build hint references make") + case strings.Contains(low, "cargo build"): + add("rust-simple", 15, defaultString(h.Source, "build hint"), "build hint references cargo build") + case strings.Contains(low, "go build"): + add("go-simple", 15, defaultString(h.Source, "build hint"), "build hint references go build") + case strings.Contains(low, "pnpm "): + add("node-pnpm-app", 15, defaultString(h.Source, "build hint"), "build hint references pnpm") + case strings.Contains(low, "yarn "): + add("node-yarn-app", 15, defaultString(h.Source, "build hint"), "build hint references yarn") + case strings.Contains(low, "npm "): + add("node-npm-app", 15, defaultString(h.Source, "build hint"), "build hint references npm") + } + } + for _, h := range a.DocHints { + low := strings.ToLower(h.Command) + if strings.Contains(low, "go-md2man") || strings.Contains(low, "md2man") { + if f.PrimaryType == "go" && f.HasMakefile { + add("go-make", 10, defaultString(h.Source, "doc hint"), "doc generation hint reinforces make-driven Go build") + } + } + } + + var out []ScoredChoice + for name, cur := range m { + out = append(out, ScoredChoice{ + Value: name, + Score: cur.score, + Confidence: inferConfidenceFromScore(cur.score), + Reason: cur.reason, + Evidence: dedupeStrings(cur.evidence), + }) + } + sortScoredChoices(out) + return out +} + +func scoreEntrypointChoices(f *RepoFacts, a *Analysis) []ScoredChoice { + type acc struct { + score int + evidence []string + reason string + } + + m := map[string]*acc{} + add := func(name string, score int, evidence, reason string) { + name = strings.TrimSpace(name) + if name == "" { + return + } + cur, ok := m[name] + if !ok { + cur = &acc{} + m[name] = cur + } + cur.score += score + if evidence != "" { + cur.evidence = append(cur.evidence, evidence) + } + if cur.reason == "" && reason != "" { + cur.reason = reason + } + } + + for _, s := range a.Services { + add(s.Name, 60, defaultString(s.UnitPath, "service unit"), "service name is a strong daemon entrypoint hint") + } + for _, c := range a.Components { + score := 30 + if c.Role == "daemon" { + score = 55 + } else if c.Role == "cli" { + score = 45 + } + add(c.Name, score, defaultString(c.Path, "component hint"), defaultString(c.Reason, "component hint suggests entrypoint")) + } + switch f.PrimaryType { + case "node": + if f.NodeBinName != "" { + add(f.NodeBinName, 40, "package.json bin", "package.json bin suggests CLI entrypoint") + } + if f.NodeMain != "" { + add("node", 30, f.NodeMain, "Node main script suggests node entrypoint") + } + case "python": + if f.PythonConsoleScript != "" { + add(f.PythonConsoleScript, 55, "console_scripts", "console script strongly suggests CLI entrypoint") + } + if f.PythonModuleName != "" { + add("python3", 35, f.PythonModuleName, "Python module suggests python -m entrypoint") + } + default: + for _, cc := range a.CandidateComponents { + add(cc, 25, "candidate component", "candidate component can serve as runtime entrypoint") + } + } + + var out []ScoredChoice + for name, cur := range m { + out = append(out, ScoredChoice{ + Value: sanitizeName(name), + Score: cur.score, + Confidence: inferConfidenceFromScore(cur.score), + Reason: cur.reason, + Evidence: dedupeStrings(cur.evidence), + }) + } + sortScoredChoices(out) + return out +} + +func scorePackageNameChoices(f *RepoFacts, a *Analysis) []ScoredChoice { + type acc struct { + score int + evidence []string + reason string + } + + m := map[string]*acc{} + add := func(name string, score int, evidence, reason string) { + name = sanitizeName(name) + if name == "" || name == "unknown" { + return + } + cur, ok := m[name] + if !ok { + cur = &acc{} + m[name] = cur + } + cur.score += score + if evidence != "" { + cur.evidence = append(cur.evidence, evidence) + } + if cur.reason == "" && reason != "" { + cur.reason = reason + } + } + + add(f.Name, 60, "repo metadata", "repository name is the default package name candidate") + for _, c := range a.CandidateComponents { + add(c, 25, "candidate component", "component name may be the intended package name") + } + if a.Runtime.Entrypoint != "" && a.Runtime.Entrypoint != "node" && a.Runtime.Entrypoint != "python3" { + add(a.Runtime.Entrypoint, 35, "runtime entrypoint", "runtime entrypoint is a strong package name candidate") + } + + var out []ScoredChoice + for name, cur := range m { + out = append(out, ScoredChoice{ + Value: name, + Score: cur.score, + Confidence: inferConfidenceFromScore(cur.score), + Reason: cur.reason, + Evidence: dedupeStrings(cur.evidence), + }) + } + sortScoredChoices(out) + return out +} + +func deriveDecisionRecords(f *RepoFacts, a *Analysis) []DecisionRecord { + var out []DecisionRecord + + if a.Alternatives != nil && len(a.Alternatives.Components) > 0 { + top := a.Alternatives.Components[0] + out = append(out, DecisionRecord{ + Field: "main_component", + Chosen: top.Value, + Confidence: top.Confidence, + Reason: top.Reason, + Evidence: append([]string(nil), top.Evidence...), + }) + } + if a.Alternatives != nil && len(a.Alternatives.BuildStyles) > 0 { + top := a.Alternatives.BuildStyles[0] + out = append(out, DecisionRecord{ + Field: "build_style", + Chosen: top.Value, + Confidence: top.Confidence, + Reason: top.Reason, + Evidence: append([]string(nil), top.Evidence...), + }) + } + if a.Runtime.Entrypoint != "" { + out = append(out, DecisionRecord{ + Field: "entrypoint", + Chosen: a.Runtime.Entrypoint, + Confidence: normalizeConfidence(a.Runtime.Confidence), + Reason: "runtime inference selected this entrypoint from service/component/language hints", + Evidence: bestAlternativeEvidence(a.Alternatives, "entrypoint"), + }) + } + if a.Alternatives != nil && len(a.Alternatives.PackageNames) > 0 { + top := a.Alternatives.PackageNames[0] + out = append(out, DecisionRecord{ + Field: "package_name", + Chosen: top.Value, + Confidence: top.Confidence, + Reason: top.Reason, + Evidence: append([]string(nil), top.Evidence...), + }) + } + + return dedupeDecisionRecords(out) +} + +func extractCommandLikeLines(s string) []string { + var out []string + for _, line := range strings.Split(s, "\n") { + trim := strings.TrimSpace(line) + if trim == "" { + continue + } + low := strings.ToLower(trim) + switch { + case strings.HasPrefix(low, "go build"), + strings.Contains(low, " go build "), + strings.HasPrefix(low, "go test"), + strings.HasPrefix(low, "cargo build"), + strings.HasPrefix(low, "cargo test"), + strings.HasPrefix(low, "npm "), + strings.HasPrefix(low, "pnpm "), + strings.HasPrefix(low, "yarn "), + strings.HasPrefix(low, "python "), + strings.HasPrefix(low, "python3 "), + strings.HasPrefix(low, "pip "), + strings.HasPrefix(low, "make"), + strings.Contains(low, " make "): + out = append(out, trim) + } + } + return dedupeStrings(out) +} + +func extractInstallLikeLines(s string) []string { + var out []string + for _, line := range strings.Split(s, "\n") { + trim := strings.TrimSpace(line) + if trim == "" { + continue + } + if strings.Contains(trim, "install ") || + strings.Contains(trim, "cp ") || + strings.Contains(trim, "mv ") || + strings.Contains(trim, "mkdir -p") || + strings.Contains(trim, "-o ") { + out = append(out, trim) + } + } + return dedupeStrings(out) +} + +func dedupeStrings(in []string) []string { + seen := map[string]struct{}{} + var out []string + for _, s := range in { + s = strings.TrimSpace(s) + if s == "" { + continue + } + if _, ok := seen[s]; ok { + continue + } + seen[s] = struct{}{} + out = append(out, s) + } + return out +} + +func commandHintsToStrings(in []CommandHint) []string { + var out []string + for _, h := range in { + if s := strings.TrimSpace(h.Command); s != "" { + out = append(out, s) + } + } + return dedupeStrings(out) +} + +func dedupeArtifactHints(in []ArtifactHint) []ArtifactHint { + seen := map[string]struct{}{} + var out []ArtifactHint + for _, a := range in { + key := strings.TrimSpace(a.Kind) + "|" + strings.TrimSpace(a.Path) + "|" + strings.TrimSpace(a.Name) + if key == "||" { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, a) + } + return out +} + +func dedupeDecisionRecords(in []DecisionRecord) []DecisionRecord { + seen := map[string]struct{}{} + var out []DecisionRecord + for _, d := range in { + key := strings.TrimSpace(d.Field) + "|" + strings.TrimSpace(d.Chosen) + if key == "|" { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, d) + } + return out +} + +func normalizeConfidence(v string) string { + switch strings.ToLower(strings.TrimSpace(v)) { + case "high": + return "high" + case "medium": + return "medium" + case "low": + return "low" + default: + return "low" + } +} + +func inferConfidenceFromScore(score int) string { + switch { + case score >= 70: + return "high" + case score >= 35: + return "medium" + default: + return "low" + } +} + +func sortScoredChoices(in []ScoredChoice) { + sort.Slice(in, func(i, j int) bool { + if in[i].Score == in[j].Score { + return in[i].Value < in[j].Value + } + return in[i].Score > in[j].Score + }) +} + +func bestAlternativeEvidence(a *Alternatives, kind string) []string { + if a == nil { + return nil + } + switch kind { + case "entrypoint": + if len(a.EntryPoints) > 0 { + return append([]string(nil), a.EntryPoints[0].Evidence...) + } + } + return nil +} + +func hasArtifactKind(in []ArtifactHint, kind string) bool { + for _, a := range in { + if strings.TrimSpace(a.Kind) == kind { + return true + } + } + return false +} + +func defaultString(v, fallback string) string { + v = strings.TrimSpace(v) + if v == "" { + return fallback + } + return v +} + +func absInt(v int) int { + if v < 0 { + return -v + } + return v +} diff --git a/internal/specgen/build.go b/internal/specgen/build.go new file mode 100644 index 000000000..0e101333a --- /dev/null +++ b/internal/specgen/build.go @@ -0,0 +1,1184 @@ +package specgen + +import ( + "context" + "fmt" + "io/fs" + "path/filepath" + "sort" + "strings" + + "github.com/project-dalec/dalec" +) + +func BuildSpec(ctx context.Context, a *Analysis, plan *SpecPlan, sourceMode string) (*dalec.Spec, []string, error) { + _ = ctx + + if a == nil || a.Facts == nil { + return nil, nil, fmt.Errorf("analysis is nil") + } + if plan == nil { + return nil, nil, fmt.Errorf("plan is nil") + } + + spec := &dalec.Spec{ + Name: chooseInitialSpecNameFromPlan(a, plan), + Version: baselineSpecVersionExpr(a, plan), + Revision: baselineSpecRevisionExpr(a, plan), + Packager: "Dalec SpecGen", + Vendor: "Dalec SpecGen", + License: firstNonEmpty(plan.License, a.Metadata.License), + Description: firstNonEmpty(plan.Description, a.Metadata.Description), + Website: firstNonEmpty(plan.Website, a.Metadata.Website), + } + + src, warnings := newSourceFromPlan(sourceMode, a, plan) + spec.Sources = map[string]dalec.Source{ + "src": src, + } + + applyPlanArgs(spec, a, plan, &warnings) + + emissionPlan := preparePlanForEmission(a, plan, &warnings) + + applySourceGenerators(spec, a, emissionPlan, &warnings) + applyPlannedDependencies(spec, emissionPlan, &warnings) + applyPlannedArtifacts(spec, emissionPlan, &warnings) + applyPlannedImage(spec, emissionPlan) + applyPlannedTests(spec, emissionPlan, &warnings) + + buildByPlan(spec, a, emissionPlan, &warnings) + + if strings.TrimSpace(emissionPlan.NetworkMode) != "" && len(spec.Build.Steps) > 0 { + spec.Build.NetworkMode = emissionPlan.NetworkMode + } + + applyPlannedTargets(spec, emissionPlan, &warnings) + + return spec, dedupeWarnings(warnings), nil +} + +func baselineSpecVersionValue(a *Analysis, plan *SpecPlan) string { + if a != nil && strings.TrimSpace(a.Metadata.Version) != "" && !looksTodo(a.Metadata.Version) { + return strings.TrimSpace(a.Metadata.Version) + } + if plan != nil && plan.Args != nil { + if v, ok := plan.Args["VERSION"]; ok && strings.TrimSpace(v) != "" { + return strings.TrimSpace(v) + } + } + return "0.1.0" +} + +func baselineSpecRevisionValue(a *Analysis, plan *SpecPlan) string { + if plan != nil && plan.Args != nil { + if v, ok := plan.Args["REVISION"]; ok && strings.TrimSpace(v) != "" { + return strings.TrimSpace(v) + } + } + return "1" +} + +func baselineSpecVersionExpr(a *Analysis, plan *SpecPlan) string { + if plan != nil && plan.Args != nil { + if _, ok := plan.Args["VERSION"]; ok { + return "${VERSION}" + } + } + return baselineSpecVersionValue(a, plan) +} + +func baselineSpecRevisionExpr(a *Analysis, plan *SpecPlan) string { + if plan != nil && plan.Args != nil { + if _, ok := plan.Args["REVISION"]; ok { + return "${REVISION}" + } + } + return baselineSpecRevisionValue(a, plan) +} + +func applyPlanArgs(spec *dalec.Spec, a *Analysis, plan *SpecPlan, warnings *[]string) { + if spec == nil || plan == nil { + return + } + if len(plan.Args) == 0 { + return + } + + if spec.Args == nil { + spec.Args = map[string]string{} + } + + for k, v := range plan.Args { + k = strings.TrimSpace(k) + if k == "" { + continue + } + + switch k { + case "VERSION": + if strings.TrimSpace(v) == "" { + v = baselineSpecVersionValue(a, plan) + } + case "REVISION": + if strings.TrimSpace(v) == "" { + v = baselineSpecRevisionValue(a, plan) + } + } + + spec.Args[k] = strings.TrimSpace(v) + } + + if _, ok := spec.Args["VERSION"]; ok && spec.Version != "${VERSION}" { + spec.Version = "${VERSION}" + } + if _, ok := spec.Args["REVISION"]; ok && spec.Revision != "${REVISION}" { + spec.Revision = "${REVISION}" + } +} + +func newSourceFromPlan(sourceMode string, a *Analysis, plan *SpecPlan) (dalec.Source, []string) { + var warnings []string + + src := dalec.Source{ + Context: &dalec.SourceContext{}, + Path: ".", + } + + switch strings.ToLower(strings.TrimSpace(sourceMode)) { + case "", "context": + return src, warnings + case "git": + f := analysisFacts(a) + if f == nil { + warnings = append(warnings, "source=git requested, but repo facts were unavailable; falling back to context source") + return src, warnings + } + if strings.TrimSpace(f.GitRemoteURL) == "" || strings.TrimSpace(f.GitCommit) == "" { + warnings = append(warnings, "source=git requested, but verified git remote/commit metadata was incomplete; falling back to context source") + return src, warnings + } + return dalec.Source{ + Git: &dalec.SourceGit{ + URL: strings.TrimSpace(f.GitRemoteURL), + Commit: strings.TrimSpace(f.GitCommit), + }, + Path: ".", + }, warnings + default: + warnings = append(warnings, fmt.Sprintf("unknown --source=%q, defaulting to context", sourceMode)) + return src, warnings + } +} + +func preparePlanForEmission(a *Analysis, plan *SpecPlan, warnings *[]string) *SpecPlan { + if plan == nil { + return nil + } + + cp := *plan + cp.Artifacts = filterPlannedArtifactsForBaselineBuild(a, plan, warnings) + cp.Dependencies = filterPlannedDependenciesForBaselineBuild(a, &cp, warnings) + if cp.GenerateTests { + cp.Tests = filterPlannedTestsForArtifacts(cp.Tests, cp.Artifacts, warnings) + } + + return &cp +} + +func filterPlannedArtifactsForBaselineBuild(a *Analysis, plan *SpecPlan, warnings *[]string) []PlannedArtifact { + if plan == nil { + return nil + } + + var out []PlannedArtifact + allowManpages := shouldEmitManpagesInBaseline(a, plan) + allowDocDirs := shouldEmitDocDirsInBaseline(a, plan) + + for _, item := range plan.Artifacts { + path := normalizeArtifactPath(item.Path) + if path == "" { + continue + } + + switch item.Kind { + case "binary": + out = append(out, item) + + case "manpage": + if strings.Contains(path, "*") { + *warnings = append(*warnings, fmt.Sprintf("dropping wildcard manpage artifact %q from baseline; prefer concrete manpage paths only", path)) + continue + } + if !allowManpages { + *warnings = append(*warnings, fmt.Sprintf("dropping manpage artifact %q because the selected baseline build does not currently produce manpages", path)) + continue + } + out = append(out, item) + + case "doc": + if isDirectoryLikeArtifactPath(path) && !allowDocDirs { + *warnings = append(*warnings, fmt.Sprintf("dropping broad docs artifact %q from baseline; keeping only concrete static docs until build/install evidence is stronger", path)) + continue + } + if strings.Contains(path, "*") { + *warnings = append(*warnings, fmt.Sprintf("dropping wildcard doc artifact %q from baseline", path)) + continue + } + out = append(out, item) + + case "config": + if strings.Contains(path, "*") { + *warnings = append(*warnings, fmt.Sprintf("dropping wildcard config artifact %q from baseline", path)) + continue + } + out = append(out, item) + + case "license": + if strings.Contains(path, "*") { + *warnings = append(*warnings, fmt.Sprintf("dropping wildcard license artifact %q from baseline", path)) + continue + } + out = append(out, item) + + case "data_dir": + out = append(out, item) + + case "libexec", "systemd": + out = append(out, item) + + default: + out = append(out, item) + } + } + + return dedupePlannedArtifacts(out) +} + +func filterPlannedDependenciesForBaselineBuild(a *Analysis, plan *SpecPlan, warnings *[]string) []PlannedDependency { + if plan == nil { + return nil + } + + var out []PlannedDependency + allowManpages := shouldEmitManpagesInBaseline(a, plan) + usingMake := false + if a != nil && strings.HasPrefix(plan.BuildStyle, "go") { + usingMake = shouldUseMakeForGoBuild(a, plan) + } + + for _, dep := range plan.Dependencies { + name := strings.ToLower(strings.TrimSpace(dep.Name)) + scope := strings.ToLower(strings.TrimSpace(dep.Scope)) + + if name == "go-md2man" && !allowManpages { + *warnings = append(*warnings, "dropping go-md2man from build deps because baseline is not emitting manpages for this build shape") + continue + } + if name == "make" && strings.HasPrefix(plan.BuildStyle, "go") && !usingMake { + continue + } + if scope == "test" && !plan.GenerateTests { + continue + } + + out = append(out, dep) + } + + return dedupePlannedDeps(out) +} + +func filterPlannedTestsForArtifacts(tests []PlannedTest, artifacts []PlannedArtifact, warnings *[]string) []PlannedTest { + if len(tests) == 0 { + return nil + } + + allowedFiles := allowedInstalledFileAssertionsFromArtifacts(artifacts) + var out []PlannedTest + + for _, pt := range tests { + next := pt + next.Files = map[string]string{} + + for path, expectation := range pt.Files { + path = strings.TrimSpace(path) + if path == "" { + continue + } + if strings.Contains(path, "*") { + *warnings = append(*warnings, fmt.Sprintf("dropping wildcard file assertion %q from test %q", path, pt.Name)) + continue + } + if _, ok := allowedFiles[path]; !ok { + *warnings = append(*warnings, fmt.Sprintf("dropping file assertion %q from test %q because it does not map to a concrete emitted artifact", path, pt.Name)) + continue + } + next.Files[path] = expectation + } + + hasSteps := len(next.Steps) > 0 + hasFiles := len(next.Files) > 0 + if !hasSteps && !hasFiles { + *warnings = append(*warnings, fmt.Sprintf("dropping empty test %q after artifact-aware filtering", pt.Name)) + continue + } + + out = append(out, next) + } + + return dedupePlannedTests(out) +} + +func allowedInstalledFileAssertionsFromArtifacts(artifacts []PlannedArtifact) map[string]struct{} { + out := map[string]struct{}{} + + for _, art := range artifacts { + switch art.Kind { + case "binary": + out["/usr/bin/"+artifactInstalledName(art, "app")] = struct{}{} + case "manpage": + path := normalizeArtifactPath(art.Path) + if path != "" && !strings.Contains(path, "*") { + out["/usr/share/man/"+manpageInstalledSubpath(path)] = struct{}{} + } + case "config": + path := normalizeArtifactPath(art.Path) + if path != "" && !strings.Contains(path, "*") { + out[defaultConfigInstallPath(path)] = struct{}{} + } + case "systemd": + path := normalizeArtifactPath(art.Path) + if path != "" && !strings.Contains(path, "*") { + out["/usr/lib/systemd/system/"+filepath.Base(path)] = struct{}{} + } + } + } + + return out +} + +func shouldEmitManpagesInBaseline(a *Analysis, plan *SpecPlan) bool { + if plan == nil { + return false + } + if plan.Intent == IntentContainerOnly { + return false + } + + if strings.HasPrefix(plan.BuildStyle, "go") { + if a == nil { + return false + } + if shouldUseMakeForGoBuild(a, plan) && hasManpageBuildHints(a) { + return true + } + return hasConcreteManpageFiles(a) + } + + if strings.Contains(strings.ToLower(plan.BuildStyle), "make") && a != nil && hasManpageBuildHints(a) { + return true + } + + return a != nil && hasConcreteManpageFiles(a) +} + +func shouldEmitDocDirsInBaseline(a *Analysis, plan *SpecPlan) bool { + if plan == nil { + return false + } + if strings.HasPrefix(plan.BuildStyle, "python") { + return true + } + if strings.Contains(strings.ToLower(plan.BuildStyle), "make") && a != nil { + for _, h := range append(append([]CommandHint(nil), a.BuildHints...), a.InstallHints2...) { + low := strings.ToLower(strings.TrimSpace(h.Command)) + if strings.Contains(low, "docs") || strings.Contains(low, "install-docs") { + return true + } + } + } + return false +} + +func hasConcreteManpageFiles(a *Analysis) bool { + if a == nil { + return false + } + for _, p := range a.ManpagePaths { + p = strings.TrimSpace(p) + if p == "" || strings.Contains(p, "*") { + continue + } + return true + } + return false +} + +func hasManpageBuildHints(a *Analysis) bool { + if a == nil { + return false + } + for _, h := range append(append([]CommandHint(nil), a.BuildHints...), a.DocHints...) { + low := strings.ToLower(strings.TrimSpace(h.Command)) + if strings.Contains(low, "go-md2man") || strings.Contains(low, "md2man") || strings.Contains(low, "make man") { + return true + } + } + return false +} + +func isDirectoryLikeArtifactPath(path string) bool { + path = normalizeArtifactPath(path) + if path == "" { + return false + } + base := filepath.Base(path) + return !strings.Contains(base, ".") +} + +func applySourceGenerators(spec *dalec.Spec, a *Analysis, plan *SpecPlan, warnings *[]string) { + adapter := selectBaselineAdapter(analysisFacts(a), plan) + adapter.ConfigureSource(spec, a, plan, warnings) +} + +func buildByPlan(spec *dalec.Spec, a *Analysis, plan *SpecPlan, warnings *[]string) { + if spec == nil || a == nil || a.Facts == nil || plan == nil { + return + } + + if plan.Intent == IntentContainerOnly { + *warnings = append(*warnings, "container-only intent selected; baseline leaves build empty instead of emitting a placeholder build step") + return + } + + adapter := selectBaselineAdapter(a.Facts, plan) + env, cmd := adapter.EmitBuild(spec, a, plan, warnings) + if strings.TrimSpace(cmd) == "" { + *warnings = append(*warnings, "no deterministic build command could be inferred; leaving build empty for manual or AI refinement") + return + } + + spec.Build = dalec.ArtifactBuild{ + Env: env, + Steps: dalec.BuildStepList{{Command: cmd}}, + } +} + +type goBuildOutput struct { + Name string + Target string + OutPath string +} + +func plannedGoBuildOutputs(plan *SpecPlan, f *RepoFacts) []goBuildOutput { + var out []goBuildOutput + + if plan != nil { + for _, art := range plan.Artifacts { + if strings.TrimSpace(art.Kind) != "binary" { + continue + } + + name := artifactInstalledName(art, sanitizeName(plan.PrimaryBinaryName)) + if name == "" || name == "unknown" { + continue + } + + target := strings.TrimSpace(art.BuildTarget) + if target == "" { + target = strings.TrimSpace(plan.PrimaryBuildTarget) + } + if target == "" { + target = chooseGoBuildTarget(f, sanitizeName(plan.MainComponent)) + } + + outPath := binaryOutputPathFromArtifact(art, name) + + out = append(out, goBuildOutput{ + Name: name, + Target: target, + OutPath: outPath, + }) + } + } + + if len(out) == 0 { + name := "app" + if plan != nil { + if s := sanitizeName(plan.PrimaryBinaryName); s != "" && s != "unknown" { + name = s + } else if s := sanitizeName(plan.MainComponent); s != "" && s != "unknown" { + name = s + } + } + + out = append(out, goBuildOutput{ + Name: name, + Target: chooseGoBuildTarget(f, sanitizeName(plan.MainComponent)), + OutPath: nativeBinaryBuildRelPath(name), + }) + } + + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +func binaryOutputPathFromArtifact(art PlannedArtifact, fallback string) string { + p := normalizeArtifactPath(art.Path) + if strings.HasPrefix(p, "src/") { + p = strings.TrimPrefix(p, "src/") + } + if strings.TrimSpace(p) != "" { + return p + } + return nativeBinaryBuildRelPath(strings.TrimSuffix(fallback, ".exe")) +} + +func buildGoByPlan(spec *dalec.Spec, a *Analysis, plan *SpecPlan, warnings *[]string, env map[string]string) string { + f := a.Facts + + env["GOFLAGS"] = "-trimpath" + + cgoVal := "0" + if plan.CGOEnabled != nil && *plan.CGOEnabled { + cgoVal = "1" + } + env["CGO_ENABLED"] = cgoVal + + isWindows := plan.Intent == IntentWindowsCross || plan.TargetFamily == TargetFamilyWindows + if isWindows { + env["GOOS"] = "windows" + } + + outputs := plannedGoBuildOutputs(plan, f) + primary := outputs[0].Name + if primary == "" || primary == "unknown" { + primary = sanitizeName(plan.MainComponent) + } + if primary == "" || primary == "unknown" { + primary = sanitizeName(spec.Name) + } + if primary == "" || primary == "unknown" { + primary = "app" + } + + useMake := shouldUseMakeForGoBuild(a, plan) + + ldflags := "-s -w" + if strings.TrimSpace(plan.LDFlagsVarPath) != "" { + if _, hasVersion := plan.Args["VERSION"]; hasVersion { + ldflags += fmt.Sprintf(" -X %s=${VERSION}", strings.TrimSpace(plan.LDFlagsVarPath)) + } + } + + if useMake { + ensureGoBuildDeps(spec, true) + ensureGoImage(spec, primary, plan) + if len(spec.Artifacts.Binaries) == 0 { + path := nativeBinaryArtifactPath(primary) + if isWindows { + path += ".exe" + } + spec.Artifacts.Binaries = map[string]dalec.ArtifactConfig{ + path: {}, + } + } + *warnings = append(*warnings, "go make-driven repo detected and Makefile looks native-build friendly; using make for baseline build") + return "cd src\nmake\n" + } + + ensureGoBuildDeps(spec, false) + ensureGoNativeArtifacts(spec, primary, plan) + ensureGoImage(spec, primary, plan) + + if f.HasMakefile { + if makeLooksCrossRelease(a) { + *warnings = append(*warnings, "Makefile release target outputs need confirmation before finalizing repo-specific artifact paths") + } + *warnings = append(*warnings, "go make-driven repo detected; using conservative native go build output until refinement") + } + + var b strings.Builder + b.WriteString("cd src\n") + b.WriteString("mkdir -p bin\n") + + for _, out := range outputs { + outPath := out.OutPath + if isWindows && !strings.HasSuffix(strings.ToLower(outPath), ".exe") { + outPath += ".exe" + } + if ldflags == "-s -w" { + fmt.Fprintf(&b, "go build -trimpath -o %s %s\n", outPath, out.Target) + continue + } + fmt.Fprintf(&b, "go build -trimpath -ldflags %q -o %s %s\n", ldflags, outPath, out.Target) + } + + return b.String() +} + +func buildRustByPlan(spec *dalec.Spec, a *Analysis, plan *SpecPlan, warnings *[]string, env map[string]string) string { + out := sanitizeName(plan.PrimaryBinaryName) + if out == "" || out == "unknown" { + out = sanitizeName(spec.Name) + } + if out == "" || out == "unknown" { + out = "app" + } + + if spec.Dependencies == nil { + spec.Dependencies = &dalec.PackageDependencies{} + } + if spec.Dependencies.Build == nil { + spec.Dependencies.Build = dalec.PackageDependencyList{} + } + spec.Dependencies.Build["rust"] = dalec.PackageConstraints{} + spec.Dependencies.Build["cargo"] = dalec.PackageConstraints{} + + if len(spec.Artifacts.Binaries) == 0 && plan.Intent != IntentContainerOnly { + spec.Artifacts.Binaries = map[string]dalec.ArtifactConfig{ + "src/target/release/" + out: {}, + } + } + + if spec.Image == nil { + spec.Image = &dalec.ImageConfig{} + } + if strings.TrimSpace(spec.Image.Entrypoint) == "" { + spec.Image.Entrypoint = out + } + if strings.TrimSpace(spec.Image.Cmd) == "" { + spec.Image.Cmd = "--help" + } + + return "cd src\ncargo build --release --locked\n" +} + +func buildNodeByPlan(spec *dalec.Spec, a *Analysis, plan *SpecPlan, warnings *[]string, env map[string]string) string { + return buildNodeCommand(a.Facts) +} + +func buildPythonByPlan(spec *dalec.Spec, a *Analysis, plan *SpecPlan, warnings *[]string, env map[string]string) string { + f := a.Facts + + if spec.Dependencies == nil { + spec.Dependencies = &dalec.PackageDependencies{} + } + if spec.Dependencies.Build == nil { + spec.Dependencies.Build = dalec.PackageDependencyList{} + } + spec.Dependencies.Build["python3"] = dalec.PackageConstraints{} + spec.Dependencies.Build["python3-pip"] = dalec.PackageConstraints{} + + if strings.Contains(plan.BuildStyle, "requirements") { + return "cd src\npython3 -m pip install -U pip\npython3 -m pip install -r requirements.txt\n" + } + + if len(spec.Artifacts.DataDirs) == 0 { + spec.Artifacts.DataDirs = map[string]dalec.ArtifactConfig{ + "src/dist": {}, + } + } + + if strings.TrimSpace(f.PythonConsoleScript) != "" { + if spec.Image == nil { + spec.Image = &dalec.ImageConfig{} + } + if strings.TrimSpace(spec.Image.Entrypoint) == "" { + spec.Image.Entrypoint = f.PythonConsoleScript + } + } + + return "cd src\npython3 -m pip install -U pip\npython3 -m pip wheel -w dist .\n" +} + +func shouldUseMakeForGoBuild(a *Analysis, plan *SpecPlan) bool { + if a == nil || a.Facts == nil || !a.Facts.HasMakefile { + return false + } + + lowStyle := strings.ToLower(strings.TrimSpace(plan.BuildStyle)) + if strings.Contains(lowStyle, "make") && !strings.Contains(lowStyle, "cross") && !makeLooksCrossRelease(a) { + for _, line := range append(append([]string{}, a.CICommands...), a.InstallHints...) { + low := strings.ToLower(strings.TrimSpace(line)) + switch { + case low == "make", + strings.HasPrefix(low, "make "), + strings.Contains(low, " make "), + strings.Contains(low, "make build"), + strings.Contains(low, "make all"), + strings.Contains(low, "make install"), + strings.Contains(low, "make binaries"), + strings.Contains(low, "make local"): + if !strings.Contains(low, "make release") { + return true + } + } + } + } + + return false +} + +func makeLooksCrossRelease(a *Analysis) bool { + if a == nil { + return false + } + + lines := append([]string{}, a.CICommands...) + lines = append(lines, a.InstallHints...) + + for _, line := range lines { + low := strings.ToLower(strings.TrimSpace(line)) + if strings.Contains(low, "make release") { + return true + } + if strings.Contains(low, "goos=") || strings.Contains(low, "goarch=") { + return true + } + if strings.Contains(low, "_linux_") || + strings.Contains(low, "_darwin_") || + strings.Contains(low, "_windows_") { + return true + } + } + + return false +} + +func ensureGoBuildDeps(spec *dalec.Spec, useMake bool) { + if spec == nil { + return + } + if spec.Dependencies == nil { + spec.Dependencies = &dalec.PackageDependencies{} + } + if spec.Dependencies.Build == nil { + spec.Dependencies.Build = dalec.PackageDependencyList{} + } + + next := dalec.PackageDependencyList{} + for k, v := range spec.Dependencies.Build { + nk := canonicalBuildDepNameForBaseline(k) + if nk == "make" && !useMake { + continue + } + next[nk] = v + } + + next["golang"] = dalec.PackageConstraints{} + if useMake { + next["make"] = dalec.PackageConstraints{} + } + + spec.Dependencies.Build = next +} + +func canonicalBuildDepNameForBaseline(name string) string { + switch strings.ToLower(strings.TrimSpace(name)) { + case "go", "golang-go": + return "golang" + default: + return strings.TrimSpace(name) + } +} + +func ensureGoNativeArtifacts(spec *dalec.Spec, out string, plan *SpecPlan) { + if spec == nil { + return + } + if len(spec.Artifacts.Binaries) > 0 { + return + } + + path := nativeBinaryArtifactPath(out) + if plan != nil && (plan.Intent == IntentWindowsCross || plan.TargetFamily == TargetFamilyWindows) { + path += ".exe" + } + + spec.Artifacts.Binaries = map[string]dalec.ArtifactConfig{ + path: {}, + } +} + +func ensureGoImage(spec *dalec.Spec, out string, plan *SpecPlan) { + if spec == nil { + return + } + if spec.Image == nil { + spec.Image = &dalec.ImageConfig{} + } + if strings.TrimSpace(spec.Image.Entrypoint) == "" { + spec.Image.Entrypoint = out + } + if strings.TrimSpace(spec.Image.Cmd) == "" { + if plan != nil && (plan.Intent == IntentWindowsCross || plan.TargetFamily == TargetFamilyWindows) { + spec.Image.Cmd = "" + } else { + spec.Image.Cmd = "--help" + } + } +} + +func buildNodeCommand(f *RepoFacts) string { + var b strings.Builder + b.WriteString("cd src\n") + + switch f.NodePackageManager { + case "pnpm": + b.WriteString("corepack enable\n") + if f.HasPnpmLock { + b.WriteString("pnpm install --frozen-lockfile\n") + } else { + b.WriteString("pnpm install\n") + } + if f.NodeHasBuild { + b.WriteString("pnpm run build\n") + } + + case "yarn": + b.WriteString("corepack enable\n") + if f.HasYarnLock { + b.WriteString("yarn install --frozen-lockfile\n") + } else { + b.WriteString("yarn install\n") + } + if f.NodeHasBuild { + b.WriteString("yarn build\n") + } + + default: + if f.HasPackageLock { + b.WriteString("npm ci\n") + } else { + b.WriteString("npm install\n") + } + if f.NodeHasBuild { + b.WriteString("npm run build\n") + } + } + + return b.String() +} + +func genericBuildCommand(f *RepoFacts) string { + if f != nil && f.HasMakefile { + return "cd src\nmake\n" + } + return "" +} + +func applyPlannedDependencies(spec *dalec.Spec, plan *SpecPlan, warnings *[]string) { + if spec == nil || plan == nil { + return + } + + if spec.Dependencies == nil { + spec.Dependencies = &dalec.PackageDependencies{} + } + if spec.Dependencies.Build == nil { + spec.Dependencies.Build = dalec.PackageDependencyList{} + } + if spec.Dependencies.Runtime == nil { + spec.Dependencies.Runtime = dalec.PackageDependencyList{} + } + if spec.Dependencies.Test == nil { + spec.Dependencies.Test = dalec.PackageDependencyList{} + } + + for _, dep := range plan.Dependencies { + if strings.TrimSpace(dep.Target) != "" { + *warnings = append(*warnings, fmt.Sprintf("target-scoped dependency %q for target %q is not yet emitted into target sections; keeping baseline root spec conservative", dep.Name, dep.Target)) + continue + } + + switch dep.Scope { + case "runtime", "recommends", "sysext": + spec.Dependencies.Runtime[dep.Name] = dalec.PackageConstraints{} + case "test": + spec.Dependencies.Test[dep.Name] = dalec.PackageConstraints{} + default: + spec.Dependencies.Build[dep.Name] = dalec.PackageConstraints{} + } + } +} + +func applyPlannedArtifacts(spec *dalec.Spec, plan *SpecPlan, warnings *[]string) { + if spec == nil || plan == nil { + return + } + + for _, item := range plan.Artifacts { + if strings.TrimSpace(item.Target) != "" { + if warnings != nil { + *warnings = append(*warnings, fmt.Sprintf( + "target-scoped artifact %q for target %q is not yet emitted into target sections; keeping baseline root spec conservative", + item.Path, + item.Target, + )) + } + continue + } + + path := normalizeArtifactPath(item.Path) + if path == "" { + continue + } + + cfg := artifactConfigFromPlannedArtifact(item) + + switch item.Kind { + case "binary": + if spec.Artifacts.Binaries == nil { + spec.Artifacts.Binaries = map[string]dalec.ArtifactConfig{} + } + spec.Artifacts.Binaries[path] = cfg + + case "manpage": + if spec.Artifacts.Manpages == nil { + spec.Artifacts.Manpages = map[string]dalec.ArtifactConfig{} + } + spec.Artifacts.Manpages[path] = cfg + + case "doc": + if spec.Artifacts.Docs == nil { + spec.Artifacts.Docs = map[string]dalec.ArtifactConfig{} + } + spec.Artifacts.Docs[path] = cfg + + case "config": + if spec.Artifacts.ConfigFiles == nil { + spec.Artifacts.ConfigFiles = map[string]dalec.ArtifactConfig{} + } + spec.Artifacts.ConfigFiles[path] = cfg + + case "license": + if spec.Artifacts.Licenses == nil { + spec.Artifacts.Licenses = map[string]dalec.ArtifactConfig{} + } + spec.Artifacts.Licenses[path] = cfg + + case "data_dir": + if spec.Artifacts.DataDirs == nil { + spec.Artifacts.DataDirs = map[string]dalec.ArtifactConfig{} + } + spec.Artifacts.DataDirs[path] = cfg + + case "libexec": + if spec.Artifacts.Libexec == nil { + spec.Artifacts.Libexec = map[string]dalec.ArtifactConfig{} + } + spec.Artifacts.Libexec[path] = cfg + + case "systemd": + if spec.Artifacts.Systemd == nil { + spec.Artifacts.Systemd = &dalec.SystemdConfiguration{} + } + if spec.Artifacts.Systemd.Units == nil { + spec.Artifacts.Systemd.Units = map[string]dalec.SystemdUnitConfig{} + } + spec.Artifacts.Systemd.Units[path] = dalec.SystemdUnitConfig{ + Name: strings.TrimSpace(item.Name), + Enable: false, + Start: false, + } + + default: + if warnings != nil { + *warnings = append(*warnings, fmt.Sprintf( + "skipping unsupported planned artifact kind %q at %s", + item.Kind, + item.Path, + )) + } + } + } +} + +func artifactConfigFromPlannedArtifact(item PlannedArtifact) dalec.ArtifactConfig { + cfg := dalec.ArtifactConfig{ + SubPath: strings.TrimSpace(item.Subpath), + Name: strings.TrimSpace(item.Name), + } + + if item.Mode != 0 { + cfg.Permissions = fs.FileMode(item.Mode) + } + if strings.TrimSpace(item.User) != "" { + cfg.User = strings.TrimSpace(item.User) + } + if strings.TrimSpace(item.Group) != "" { + cfg.Group = strings.TrimSpace(item.Group) + } + + return cfg +} + +func applyPlannedImage(spec *dalec.Spec, plan *SpecPlan) { + if spec == nil || plan == nil { + return + } + + if plan.Intent == IntentPackage || plan.Intent == IntentSysext { + if strings.TrimSpace(plan.Entrypoint) == "" { + return + } + } + + if strings.TrimSpace(plan.Entrypoint) == "" && strings.TrimSpace(plan.Cmd) == "" { + return + } + + if spec.Image == nil { + spec.Image = &dalec.ImageConfig{} + } + if strings.TrimSpace(plan.Entrypoint) != "" { + spec.Image.Entrypoint = strings.TrimSpace(plan.Entrypoint) + } + if strings.TrimSpace(plan.Cmd) != "" { + spec.Image.Cmd = strings.TrimSpace(plan.Cmd) + } +} + +func applyPlannedTests(spec *dalec.Spec, plan *SpecPlan, warnings *[]string) { + if spec == nil || plan == nil || !plan.GenerateTests { + return + } + + for _, pt := range plan.Tests { + if strings.TrimSpace(pt.Target) != "" { + *warnings = append(*warnings, fmt.Sprintf("target-scoped test %q for target %q is not yet emitted into target sections; keeping baseline root spec conservative", pt.Name, pt.Target)) + continue + } + + var steps []dalec.TestStep + for _, cmd := range pt.Steps { + cmd = strings.TrimSpace(cmd) + if cmd == "" { + continue + } + steps = append(steps, dalec.TestStep{Command: cmd}) + } + + files := map[string]dalec.FileCheckOutput{} + for path, expectation := range pt.Files { + expectation = strings.TrimSpace(expectation) + if expectation == "" || strings.EqualFold(expectation, "exists") { + files[path] = dalec.FileCheckOutput{} + continue + } + files[path] = dalec.FileCheckOutput{ + CheckOutput: dalec.CheckOutput{ + Contains: []string{expectation}, + }, + } + } + + test := &dalec.TestSpec{ + Name: pt.Name, + Dir: pt.Dir, + Steps: steps, + } + if len(files) > 0 { + test.Files = files + } + spec.Tests = append(spec.Tests, test) + } +} + +func applyPlannedTargets(spec *dalec.Spec, plan *SpecPlan, warnings *[]string) { + if spec == nil || plan == nil || !plan.UseTargets { + return + } + if len(plan.Routes) == 0 { + return + } + + if spec.Targets == nil { + spec.Targets = map[string]dalec.Target{} + } + + for _, route := range plan.Routes { + targetName := strings.TrimSpace(route.Name) + if targetName == "" { + continue + } + + t := dalec.Target{} + + isContainerRoute := route.Subtarget == "container" || + plan.Intent == IntentPackageContainer || + plan.Intent == IntentContainerOnly + + if isContainerRoute && strings.TrimSpace(plan.Entrypoint) != "" { + t.Image = &dalec.ImageConfig{ + Entrypoint: strings.TrimSpace(plan.Entrypoint), + Cmd: strings.TrimSpace(plan.Cmd), + } + } + + spec.Targets[targetName] = t + } + + if len(spec.Targets) == 0 { + spec.Targets = nil + } +} + +func chooseInitialSpecNameFromPlan(a *Analysis, plan *SpecPlan) string { + if plan != nil { + if s := sanitizeName(plan.PackageName); s != "" && s != "unknown" { + return s + } + if s := sanitizeName(plan.MainComponent); s != "" && s != "unknown" { + return s + } + } + + if a == nil || a.Facts == nil { + return "unknown" + } + + name := sanitizeName(a.Metadata.Name) + if name != "" && !looksSemanticMajorOnly(name) { + return name + } + + base := sanitizeName(filepath.Base(a.Facts.RepoDir)) + if base != "" && !looksSemanticMajorOnly(base) { + return base + } + + if name != "" { + return name + } + return "unknown" +} + +func chooseGoBuildTarget(f *RepoFacts, outBin string) string { + if f == nil { + return "." + } + + outBin = sanitizeName(outBin) + + if len(f.GoMainCandidates) == 1 { + rel := f.GoMainCandidates[0] + if rel == "." || rel == "" { + return "." + } + return "./" + strings.TrimPrefix(rel, "./") + } + + for _, rel := range f.GoMainCandidates { + if sanitizeName(filepath.Base(rel)) == outBin { + return "./" + strings.TrimPrefix(rel, "./") + } + } + + if f.GoMainRel != "" && f.GoMainRel != "." { + return "./" + strings.TrimPrefix(f.GoMainRel, "./") + } + + return "." +} diff --git a/internal/specgen/compat.go b/internal/specgen/compat.go new file mode 100644 index 000000000..b3da35e31 --- /dev/null +++ b/internal/specgen/compat.go @@ -0,0 +1,88 @@ +package specgen + +import ( + "path/filepath" + "strings" +) + +// AIFile is a compact file payload used by the optional AI-refinement path. +// The baseline generator keeps this type even when AI is disabled so the +// package remains source-compatible as the feature set evolves. +type AIFile struct { + Path string `json:"path"` + Content string `json:"content"` +} + +func dedupeWarnings(in []string) []string { + seen := map[string]struct{}{} + out := make([]string, 0, len(in)) + for _, w := range in { + w = strings.TrimSpace(w) + if w == "" { + continue + } + if _, ok := seen[w]; ok { + continue + } + seen[w] = struct{}{} + out = append(out, w) + } + return out +} + +func normalizeArtifactPath(path string) string { + path = strings.TrimSpace(path) + if path == "" { + return "" + } + + path = strings.ReplaceAll(path, "\\", "/") + + // Keep artifact paths repo-relative and stable in emitted YAML/tests. + for strings.HasPrefix(path, "./") { + path = strings.TrimPrefix(path, "./") + } + path = strings.TrimPrefix(path, "/") + + cleaned := filepath.ToSlash(filepath.Clean(path)) + if cleaned == "." { + return "" + } + for strings.HasPrefix(cleaned, "./") { + cleaned = strings.TrimPrefix(cleaned, "./") + } + return cleaned +} + +func looksDangerous(cmd string) bool { + cmd = strings.ToLower(strings.TrimSpace(cmd)) + if cmd == "" { + return false + } + + dangerous := []string{ + "curl | sh", + "curl|sh", + "wget | sh", + "wget|sh", + "curl -s", + "curl -fs", + "curl -sf", + "bash -c \"$(curl", + "sh -c \"$(curl", + "bash <(curl", + "sh <(curl", + "nc -e ", + "mkfifo ", + "rm -rf /", + "sudo rm -rf /", + } + + for _, needle := range dangerous { + if strings.Contains(cmd, needle) { + return true + } + } + + return false +} diff --git a/internal/specgen/detect.go b/internal/specgen/detect.go new file mode 100644 index 000000000..379717d8c --- /dev/null +++ b/internal/specgen/detect.go @@ -0,0 +1,1747 @@ +package specgen + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strings" +) + +type RepoFacts struct { + RepoDir string + PrimaryType string // go|rust|node|python|unknown + + TypeCandidates []string + + GitCommit string + GitRemoteURL string + + Name string + Description string + Website string + License string + Version string + + HasGoMod bool + HasCargoToml bool + HasPackageJSON bool + HasPyProject bool + HasRequirements bool + HasSetupPy bool + HasMakefile bool + HasDockerfile bool + HasContainerfile bool + + HasPackageLock bool + HasPnpmLock bool + HasYarnLock bool + + ReadmePath string + LicensePath string + + GoMainRel string + GoMainCandidates []string + + NodeHasBuild bool + NodeMain string + NodeBinName string + NodeBinPath string + NodeScriptUsesCargo bool + NodePackageManager string + + CargoPackageName string + CargoBinName string + + PythonModuleName string + PythonConsoleScript string + + SuggestedSourceGenerator string + SuggestedSourceGeneratorSafe bool + SuggestedSourceGeneratorReason string + GoModuleDirective string + GoModuleToolchain string + GoModuleHasToolBlock bool + GoVersionNormalized string + GoManagedToolchainVersion string + GoNeedsManagedToolchain bool + + BuildHints []CommandHint + TestHints []CommandHint + InstallHints2 []CommandHint + DocHints []CommandHint + MakeTargets []MakeTargetHint + Components []ComponentHint + ManpagePaths []string + ConfigPaths []string + Services []ServiceHint +} + +func looksInstallScriptURL(s string) bool { + s = strings.ToLower(strings.TrimSpace(s)) + return strings.HasSuffix(s, ".sh") || + strings.Contains(s, "/install.sh") || + strings.Contains(s, "/install") +} + +func normalizeManifestDescription(s string) string { + s = normalizeReadmeLine(s) + if looksTodo(s) { + return s + } + if len(s) > 220 { + s = truncateAtWord(s, 220) + } + return s +} + +func DetectRepo(repoDir string, forcedType string) (*RepoFacts, []string, error) { + var warnings []string + + f := &RepoFacts{ + RepoDir: repoDir, + Name: sanitizeName(filepath.Base(repoDir)), + Description: "TODO: describe this package", + License: "TODO", + Version: "", + } + + descriptionFromManifest := false + + exists := func(p string) bool { + _, err := os.Stat(filepath.Join(repoDir, p)) + return err == nil + } + + f.HasGoMod = exists("go.mod") + f.HasCargoToml = exists("Cargo.toml") + f.HasPackageJSON = exists("package.json") + f.HasPyProject = exists("pyproject.toml") + f.HasRequirements = exists("requirements.txt") + f.HasSetupPy = exists("setup.py") + f.HasMakefile = exists("Makefile") || exists("GNUmakefile") || exists("makefile") + f.HasDockerfile = exists("Dockerfile") + f.HasContainerfile = exists("Containerfile") + f.HasPackageLock = exists("package-lock.json") + f.HasPnpmLock = exists("pnpm-lock.yaml") + f.HasYarnLock = exists("yarn.lock") + + if readmePath, readmeText, ok := firstExistingTextFile( + repoDir, + []string{"README.md", "README", "README.rst", "README.txt"}, + 64*1024, + ); ok { + f.ReadmePath = readmePath + if desc := firstReadmeSentence(readmeText); desc != "" { + f.Description = desc + } + // website set here only as last resort; go.mod URL takes priority below + if f.Website == "" { + if site := firstUsefulReadmeURL(readmeText); site != "" { + f.Website = normalizeRepoURL(site) + } + } + } + + if licensePath, licenseText, ok := firstExistingTextFile( + repoDir, + []string{"LICENSE", "LICENSE.txt", "LICENSE.md", "COPYING"}, + 64*1024, + ); ok { + f.LicensePath = licensePath + if lic := detectLicenseFromText(licenseText); lic != "" { + f.License = lic + } + } + + if f.HasGoMod { + if modPath := parseGoModulePath(repoDir); modPath != "" { + if modName := moduleLeafName(modPath); modName != "" && f.Name == sanitizeName(filepath.Base(repoDir)) { + f.Name = modName + } + // go.mod URL is more reliable than README URL — always prefer it + if u := modulePathToRepoURL(modPath); u != "" { + f.Website = u + } + } + } + + if f.HasPackageJSON { + before := f.Description + enrichFromPackageJSON(repoDir, f) + if f.Description != before && !looksTodo(f.Description) { + descriptionFromManifest = true + } + } + if f.HasCargoToml { + before := f.Description + enrichFromCargoToml(repoDir, f) + if f.Description != before && !looksTodo(f.Description) { + descriptionFromManifest = true + } + } + if f.HasPyProject { + before := f.Description + enrichFromPyProject(repoDir, f) + if f.Description != before && !looksTodo(f.Description) { + descriptionFromManifest = true + } + } + if f.HasSetupPy && f.PythonModuleName == "" { + f.PythonModuleName = strings.ReplaceAll(f.Name, "-", "_") + } + + f.GoMainCandidates = detectGoMainCandidates(repoDir) + switch len(f.GoMainCandidates) { + case 1: + f.GoMainRel = f.GoMainCandidates[0] + case 0: + default: + f.GoMainRel = f.GoMainCandidates[0] + warnings = append(warnings, fmt.Sprintf("go: multiple main package candidates found; defaulting to %s", f.GoMainRel)) + } + + collectRepoSignals(repoDir, f) + + detected, typeCandidates := detectPrimaryTypeAndCandidates(f) + f.TypeCandidates = typeCandidates + + if forcedType != "" { + f.PrimaryType = forcedType + if detected != "" && detected != "unknown" && forcedType != detected { + warnings = append(warnings, fmt.Sprintf("repo type forced to %q (auto-detected %q)", forcedType, detected)) + } + } else { + f.PrimaryType = detected + } + + if f.PrimaryType == "unknown" { + warnings = append(warnings, "could not confidently detect repo type (no strong manifest/build evidence)") + } + + if f.PrimaryType == "go" { + switch { + case f.GoMainRel == "": + warnings = append(warnings, "go: could not confidently find a main package; baseline build uses '.' and may need adjustment") + f.GoMainRel = "." + case f.GoMainRel != ".": + warnings = append(warnings, fmt.Sprintf("go: using main package at %s", f.GoMainRel)) + } + } + + if f.PrimaryType == "node" { + switch { + case f.HasPnpmLock: + f.NodePackageManager = "pnpm" + case f.HasYarnLock: + f.NodePackageManager = "yarn" + default: + f.NodePackageManager = "npm" + } + } + + determineSuggestedSourceGenerator(repoDir, f) + + if f.PrimaryType == "python" && f.PythonModuleName == "" { + f.PythonModuleName = strings.ReplaceAll(f.Name, "-", "_") + } + + if f.PrimaryType == "rust" && f.CargoBinName == "" && f.CargoPackageName != "" { + f.CargoBinName = sanitizeName(f.CargoPackageName) + } + + if strings.TrimSpace(f.Version) == "" { + f.Version = detectVersionHint(repoDir) + } + + detectGitMetadata(repoDir, f) + + if !descriptionFromManifest { + f.Description = normalizeDetectedDescription(f.Description) + } + f.Website = normalizeRepoURL(f.Website) + f.GitRemoteURL = normalizeRepoURL(f.GitRemoteURL) + + return f, warnings, nil +} + +func detectGitMetadata(repoDir string, f *RepoFacts) { + if f == nil { + return + } + + if commit := gitOutput(repoDir, "rev-parse", "HEAD"); commit != "" { + f.GitCommit = commit + } + + if remote := gitOutput(repoDir, "config", "--get", "remote.origin.url"); remote != "" { + f.GitRemoteURL = normalizeRepoURL(remote) + return + } + + if remote := gitOutput(repoDir, "remote", "get-url", "origin"); remote != "" { + f.GitRemoteURL = normalizeRepoURL(remote) + return + } + + if strings.TrimSpace(f.Website) != "" { + f.GitRemoteURL = normalizeRepoURL(f.Website) + } +} + +func gitOutput(repoDir string, args ...string) string { + cmdArgs := append([]string{"-C", repoDir}, args...) + cmd := exec.Command("git", cmdArgs...) + out, err := cmd.Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +func detectPrimaryTypeAndCandidates(f *RepoFacts) (string, []string) { + type scoreItem struct { + name string + score int + } + + var items []scoreItem + + goScore := 0 + if f.HasGoMod { + goScore += 10 + } + if len(f.GoMainCandidates) > 0 { + goScore += 3 * len(f.GoMainCandidates) + } + if goScore > 0 { + items = append(items, scoreItem{name: "go", score: goScore}) + } + + rustScore := 0 + if f.HasCargoToml { + rustScore += 10 + } + if f.CargoBinName != "" { + rustScore += 3 + } else if f.CargoPackageName != "" { + rustScore += 2 + } + if prefersRustForNodeWrapperRepo(f) { + rustScore += 6 + } + if rustScore > 0 { + items = append(items, scoreItem{name: "rust", score: rustScore}) + } + + nodeScore := 0 + if f.HasPackageJSON { + nodeScore += 6 + } + if f.NodeBinName != "" { + nodeScore += 2 + } + if f.NodeMain != "" { + nodeScore += 2 + } + if f.NodeHasBuild { + nodeScore += 2 + } + if prefersRustForNodeWrapperRepo(f) { + nodeScore -= 3 + } + if nodeScore > 0 { + items = append(items, scoreItem{name: "node", score: nodeScore}) + } + + pythonScore := 0 + if f.HasPyProject || f.HasRequirements || f.HasSetupPy { + pythonScore += 6 + } + if f.PythonConsoleScript != "" { + pythonScore += 3 + } + if f.PythonModuleName != "" { + pythonScore += 1 + } + if pythonScore > 0 { + items = append(items, scoreItem{name: "python", score: pythonScore}) + } + + if len(items) == 0 { + return "unknown", nil + } + + sort.Slice(items, func(i, j int) bool { + if items[i].score == items[j].score { + return items[i].name < items[j].name + } + return items[i].score > items[j].score + }) + + var out []string + for _, it := range items { + out = append(out, it.name) + } + return items[0].name, out +} + +func collectRepoSignals(repoDir string, f *RepoFacts) { + f.MakeTargets = parseMakeTargets(repoDir) + + ciHints := collectCICommandHints(repoDir) + + var buildHints []CommandHint + var testHints []CommandHint + var installHints []CommandHint + var docHints []CommandHint + + for _, mt := range f.MakeTargets { + kind := classifyMakeTarget(mt.Name) + for _, cmd := range mt.Commands { + h := CommandHint{ + Command: strings.TrimSpace(cmd), + Source: "Makefile", + Kind: kind, + Confidence: "medium", + Reason: "make target command", + } + switch kind { + case "test": + testHints = append(testHints, h) + case "install": + installHints = append(installHints, h) + case "doc": + docHints = append(docHints, h) + default: + buildHints = append(buildHints, h) + } + } + if len(mt.Commands) == 0 { + h := CommandHint{ + Command: "make " + mt.Name, + Source: "Makefile", + Kind: kind, + Confidence: "low", + Reason: "make target detected", + } + switch kind { + case "test": + testHints = append(testHints, h) + case "install": + installHints = append(installHints, h) + case "doc": + docHints = append(docHints, h) + default: + buildHints = append(buildHints, h) + } + } + } + + for _, h := range ciHints { + switch h.Kind { + case "test": + testHints = append(testHints, h) + case "install": + installHints = append(installHints, h) + case "doc": + docHints = append(docHints, h) + default: + buildHints = append(buildHints, h) + } + } + + f.BuildHints = dedupeCommandHints(buildHints) + f.TestHints = dedupeCommandHints(testHints) + f.InstallHints2 = dedupeCommandHints(installHints) + f.DocHints = dedupeCommandHints(docHints) + + f.ManpagePaths = collectManpagePaths(repoDir) + f.ConfigPaths = collectConfigPaths(repoDir) + f.Services = collectServiceHints(repoDir) + f.Components = collectComponentHints(f) +} + +func collectCICommandHints(repoDir string) []CommandHint { + var out []CommandHint + roots := []string{ + filepath.Join(repoDir, ".github", "workflows"), + } + + for _, root := range roots { + entries, err := os.ReadDir(root) + if err != nil { + continue + } + for _, e := range entries { + if e.IsDir() { + continue + } + full := filepath.Join(root, e.Name()) + b, err := readSmallTextFile(full, 256*1024) + if err != nil { + continue + } + out = append(out, extractClassifiedCommandHints(filepath.ToSlash(filepath.Join(".github/workflows", e.Name())), string(b))...) + } + } + + for _, name := range []string{"azure-pipelines.yml", ".gitlab-ci.yml"} { + full := filepath.Join(repoDir, name) + b, err := readSmallTextFile(full, 256*1024) + if err != nil { + continue + } + out = append(out, extractClassifiedCommandHints(name, string(b))...) + } + + return dedupeCommandHints(out) +} + +func extractClassifiedCommandHints(source, content string) []CommandHint { + var out []CommandHint + for _, raw := range strings.Split(content, "\n") { + trim := strings.TrimSpace(raw) + if trim == "" { + continue + } + kind := classifyCommandLine(trim) + if kind == "" { + continue + } + out = append(out, CommandHint{ + Command: trim, + Source: source, + Kind: kind, + Confidence: "medium", + Reason: "command-like CI/build line detected", + }) + } + return out +} + +func classifyCommandLine(line string) string { + low := strings.ToLower(strings.TrimSpace(line)) + if low == "" { + return "" + } + + switch { + case strings.HasPrefix(low, "go test"), + strings.HasPrefix(low, "cargo test"), + strings.HasPrefix(low, "pytest"), + strings.HasPrefix(low, "npm test"), + strings.HasPrefix(low, "pnpm test"), + strings.HasPrefix(low, "yarn test"), + strings.Contains(low, " make test"), + strings.HasPrefix(low, "make test"), + strings.Contains(low, "make check"), + strings.Contains(low, "go test "): + return "test" + + case strings.Contains(low, "install "), + strings.Contains(low, "mkdir -p"), + strings.Contains(low, "cp "), + strings.Contains(low, " mv "), + strings.HasPrefix(low, "make install"), + strings.Contains(low, " make install"): + return "install" + + case strings.Contains(low, "man"), + strings.Contains(low, "docs"), + strings.Contains(low, "md2man"), + strings.Contains(low, "go-md2man"): + return "doc" + + case strings.Contains(low, "release"), + strings.Contains(low, "goreleaser"): + return "release" + + case strings.HasPrefix(low, "go build"), + strings.HasPrefix(low, "cargo build"), + strings.HasPrefix(low, "npm "), + strings.HasPrefix(low, "pnpm "), + strings.HasPrefix(low, "yarn "), + strings.HasPrefix(low, "python "), + strings.HasPrefix(low, "python3 "), + strings.HasPrefix(low, "pip "), + strings.HasPrefix(low, "make"), + strings.Contains(low, " make "): + return "build" + + default: + return "" + } +} + +func classifyMakeTarget(name string) string { + low := strings.ToLower(strings.TrimSpace(name)) + switch { + case low == "test" || low == "check" || strings.Contains(low, "test"): + return "test" + case low == "install" || strings.Contains(low, "install"): + return "install" + case low == "man" || low == "docs" || strings.Contains(low, "doc"): + return "doc" + case low == "release" || low == "dist" || strings.Contains(low, "release"): + return "release" + default: + return "build" + } +} + +func parseMakeTargets(repoDir string) []MakeTargetHint { + var full string + for _, name := range []string{"Makefile", "GNUmakefile", "makefile"} { + p := filepath.Join(repoDir, name) + if _, err := os.Stat(p); err == nil { + full = p + break + } + } + if full == "" { + return nil + } + + b, err := readSmallTextFile(full, 256*1024) + if err != nil { + return nil + } + + lines := strings.Split(string(b), "\n") + var out []MakeTargetHint + var current *MakeTargetHint + + flush := func() { + if current == nil { + return + } + current.Commands = dedupeStrings(current.Commands) + out = append(out, *current) + current = nil + } + + for _, raw := range lines { + line := strings.TrimRight(raw, "\r") + trim := strings.TrimSpace(line) + if trim == "" { + continue + } + + if !strings.HasPrefix(line, "\t") && !strings.HasPrefix(line, " ") && strings.Contains(line, ":") && !strings.Contains(line, ":=") { + left := strings.TrimSpace(strings.SplitN(line, ":", 2)[0]) + if left == "" || strings.Contains(left, "=") || strings.HasPrefix(left, ".") { + continue + } + flush() + name := strings.Fields(left)[0] + current = &MakeTargetHint{ + Name: name, + Confidence: "medium", + Reason: "top-level make target detected", + } + continue + } + + if current != nil && (strings.HasPrefix(line, "\t") || strings.HasPrefix(line, " ")) { + cmd := strings.TrimSpace(line) + if cmd != "" && !strings.HasPrefix(cmd, "#") { + current.Commands = append(current.Commands, cmd) + } + } + } + flush() + + return dedupeMakeTargetHints(out) +} + +func collectManpagePaths(repoDir string) []string { + var out []string + roots := []string{"man", "docs/man"} + for _, root := range roots { + full := filepath.Join(repoDir, root) + _ = filepath.Walk(full, func(path string, info os.FileInfo, err error) error { + if err != nil || info == nil || info.IsDir() { + return nil + } + name := info.Name() + if strings.HasSuffix(name, ".1") || strings.HasSuffix(name, ".5") || strings.HasSuffix(name, ".8") { + if rel, err := filepath.Rel(repoDir, path); err == nil { + out = append(out, filepath.ToSlash(rel)) + } + } + return nil + }) + } + sort.Strings(out) + return dedupeStrings(out) +} + +func collectConfigPaths(repoDir string) []string { + var out []string + roots := []string{"config", "configs", "deploy", "packaging"} + for _, root := range roots { + full := filepath.Join(repoDir, root) + entries, err := os.ReadDir(full) + if err != nil { + continue + } + for _, e := range entries { + if e.IsDir() { + continue + } + name := strings.ToLower(e.Name()) + if strings.HasSuffix(name, ".yaml") || strings.HasSuffix(name, ".yml") || strings.HasSuffix(name, ".json") || strings.HasSuffix(name, ".toml") || strings.HasSuffix(name, ".conf") { + out = append(out, filepath.ToSlash(filepath.Join(root, e.Name()))) + } + } + } + sort.Strings(out) + return dedupeStrings(out) +} + +func collectServiceHints(repoDir string) []ServiceHint { + var out []ServiceHint + paths := []string{ + "systemd", + "packaging/systemd", + "deploy/systemd", + } + for _, root := range paths { + full := filepath.Join(repoDir, root) + entries, err := os.ReadDir(full) + if err != nil { + continue + } + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".service") { + continue + } + out = append(out, ServiceHint{ + Name: strings.TrimSuffix(e.Name(), ".service"), + UnitPath: filepath.ToSlash(filepath.Join(root, e.Name())), + Confidence: "medium", + }) + } + } + return dedupeServiceHints(out) +} + +// internalToolName returns true for directory names that are build-time +// codegen helpers or internal tooling, not installable package artifacts. +// Used to filter subdirs when scanning cmd/, app/, and bin/ for main packages. +func internalToolName(name string) bool { + prefixes := []string{ + "gen-", "protoc-", "go-build", "go-plugin", + "mockgen", "stringer", "wire", "inject", + "mage", "task", + } + low := strings.ToLower(strings.TrimSpace(name)) + for _, prefix := range prefixes { + if strings.HasPrefix(low, prefix) { + return true + } + } + // Exact matches for directories that are always tooling, never artifacts. + switch low { + case "gen", "tools", "hack", "scripts", "internal": + return true + } + return false +} + +func collectComponentHints(f *RepoFacts) []ComponentHint { + var out []ComponentHint + add := func(name, path, language, role, confidence, reason string) { + name = sanitizeName(name) + if name == "" || name == "unknown" { + return + } + out = append(out, ComponentHint{ + Name: name, + Path: path, + Language: language, + Role: role, + Confidence: confidence, + Reason: reason, + }) + } + + for _, rel := range f.GoMainCandidates { + name := filepath.Base(rel) + if rel == "." || rel == "" { + switch { + case sanitizeName(f.Name) != "" && sanitizeName(f.Name) != "unknown": + name = f.Name + default: + name = filepath.Base(f.RepoDir) + } + } + add(name, rel, "go", "cli", "medium", "go main package detected") + } + + if f.CargoBinName != "" { + add(f.CargoBinName, "", "rust", "cli", "medium", "cargo binary detected") + } + if f.NodeBinName != "" { + add(f.NodeBinName, "", "node", "cli", "medium", "node bin entry detected") + } + if f.PythonConsoleScript != "" { + add(f.PythonConsoleScript, "", "python", "cli", "high", "python console script detected") + } + if len(f.Services) > 0 { + for _, s := range f.Services { + add(s.Name, s.UnitPath, f.PrimaryType, "daemon", "medium", "systemd service detected") + } + } + if len(out) == 0 && f.Name != "" { + add(f.Name, "", f.PrimaryType, "cli", "low", "repository name fallback") + } + + return dedupeComponentHints(out) +} + +func dedupeCommandHints(in []CommandHint) []CommandHint { + seen := map[string]struct{}{} + out := make([]CommandHint, 0, len(in)) + for _, item := range in { + key := item.Kind + "|" + item.Source + "|" + item.Command + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, item) + } + return out +} + +func dedupeMakeTargetHints(in []MakeTargetHint) []MakeTargetHint { + seen := map[string]struct{}{} + out := make([]MakeTargetHint, 0, len(in)) + for _, item := range in { + key := item.Name + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, item) + } + return out +} + +func dedupeComponentHints(in []ComponentHint) []ComponentHint { + seen := map[string]struct{}{} + out := make([]ComponentHint, 0, len(in)) + for _, item := range in { + key := item.Name + "|" + item.Path + "|" + item.Role + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, item) + } + return out +} + +func dedupeServiceHints(in []ServiceHint) []ServiceHint { + seen := map[string]struct{}{} + out := make([]ServiceHint, 0, len(in)) + for _, item := range in { + key := item.Name + "|" + item.UnitPath + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, item) + } + return out +} + +func readSmallTextFile(path string, maxBytes int) ([]byte, error) { + b, err := os.ReadFile(path) + if err != nil { + return nil, err + } + if maxBytes > 0 && len(b) > maxBytes { + b = b[:maxBytes] + } + return b, nil +} + +func enrichFromPackageJSON(repoDir string, f *RepoFacts) { + type repoField struct { + URL string `json:"url"` + } + type pkg struct { + Name string `json:"name"` + Version string `json:"version"` + License string `json:"license"` + Description string `json:"description"` + Homepage string `json:"homepage"` + Repository any `json:"repository"` + Scripts map[string]string `json:"scripts"` + Main string `json:"main"` + Bin any `json:"bin"` + } + + b, err := os.ReadFile(filepath.Join(repoDir, "package.json")) + if err != nil { + return + } + var p pkg + if err := json.Unmarshal(b, &p); err != nil { + return + } + + if p.Name != "" { + f.Name = sanitizeName(p.Name) + } + if p.Version != "" { + f.Version = p.Version + } + if p.License != "" { + f.License = p.License + } + if p.Description != "" { + f.Description = normalizeManifestDescription(p.Description) + } + if p.Homepage != "" { + f.Website = normalizeRepoURL(p.Homepage) + } + + repoURL := "" + switch r := p.Repository.(type) { + case string: + repoURL = normalizeRepoURL(r) + case map[string]any: + if u, ok := r["url"].(string); ok && u != "" { + repoURL = normalizeRepoURL(u) + } + case repoField: + if r.URL != "" { + repoURL = normalizeRepoURL(r.URL) + } + } + if repoURL != "" && (f.Website == "" || looksInstallScriptURL(f.Website)) { + f.Website = repoURL + } + + if _, ok := p.Scripts["build"]; ok { + f.NodeHasBuild = true + } + for _, cmd := range p.Scripts { + low := strings.ToLower(strings.TrimSpace(cmd)) + if strings.Contains(low, "cargo ") || strings.Contains(low, "cargo-") { + f.NodeScriptUsesCargo = true + } + } + if p.Main != "" { + f.NodeMain = p.Main + } + switch b := p.Bin.(type) { + case string: + f.NodeBinName = sanitizeName(f.Name) + f.NodeBinPath = filepath.ToSlash(strings.TrimSpace(b)) + case map[string]any: + type nodeBinEntry struct { + name string + path string + } + var entries []nodeBinEntry + for k, raw := range b { + path, _ := raw.(string) + entries = append(entries, nodeBinEntry{ + name: sanitizeName(k), + path: filepath.ToSlash(strings.TrimSpace(path)), + }) + } + sort.Slice(entries, func(i, j int) bool { + return entries[i].name < entries[j].name + }) + if len(entries) > 0 { + f.NodeBinName = entries[0].name + f.NodeBinPath = entries[0].path + } + } +} + +func prefersRustForNodeWrapperRepo(f *RepoFacts) bool { + if f == nil || !f.HasCargoToml || !f.HasPackageJSON { + return false + } + cargoBin := sanitizeName(f.CargoBinName) + if cargoBin == "" { + cargoBin = sanitizeName(f.CargoPackageName) + } + if cargoBin == "" { + return false + } + if f.NodeScriptUsesCargo { + return true + } + nodeBin := sanitizeName(f.NodeBinName) + if nodeBin == "" || cargoBin == "" || nodeBin != cargoBin { + return false + } + path := strings.ToLower(filepath.ToSlash(strings.TrimSpace(f.NodeBinPath))) + if path == "" { + path = strings.ToLower(filepath.ToSlash(strings.TrimSpace(f.NodeMain))) + } + if path == "" { + return false + } + return strings.HasPrefix(path, "bin/") || + strings.HasPrefix(path, "scripts/") || + strings.HasSuffix(path, ".js") || + strings.HasSuffix(path, ".mjs") || + strings.HasSuffix(path, ".cjs") +} + +func enrichFromCargoToml(repoDir string, f *RepoFacts) { + b, err := os.ReadFile(filepath.Join(repoDir, "Cargo.toml")) + if err != nil { + return + } + lines := strings.Split(string(b), "\n") + section := "" + for _, raw := range lines { + line := cleanTOMLLine(raw) + if line == "" { + continue + } + if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { + section = strings.TrimSpace(line) + continue + } + key, val, ok := parseTOMLKV(line) + if !ok { + continue + } + switch section { + case "[package]": + switch key { + case "name": + f.CargoPackageName = sanitizeName(val) + f.Name = sanitizeName(val) + case "version": + f.Version = val + case "description": + f.Description = normalizeManifestDescription(val) + case "license": + f.License = val + case "homepage": + if f.Website == "" { + f.Website = normalizeRepoURL(val) + } + case "repository": + repoURL := normalizeRepoURL(val) + if repoURL != "" && (f.Website == "" || looksInstallScriptURL(f.Website)) { + f.Website = repoURL + } + } + case "[[bin]]": + if key == "name" && f.CargoBinName == "" { + f.CargoBinName = sanitizeName(val) + } + } + } +} + +func enrichFromPyProject(repoDir string, f *RepoFacts) { + b, err := os.ReadFile(filepath.Join(repoDir, "pyproject.toml")) + if err != nil { + return + } + lines := strings.Split(string(b), "\n") + section := "" + for _, raw := range lines { + line := cleanTOMLLine(raw) + if line == "" { + continue + } + if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { + section = strings.TrimSpace(line) + continue + } + key, val, ok := parseTOMLKV(line) + if !ok { + continue + } + switch section { + case "[project]": + switch key { + case "name": + f.Name = sanitizeName(val) + f.PythonModuleName = strings.ReplaceAll(sanitizeName(val), "-", "_") + case "version": + f.Version = val + case "description": + f.Description = normalizeManifestDescription(val) + case "license": + if val != "" { + f.License = val + } + } + case "[project.urls]": + if strings.EqualFold(key, "homepage") && f.Website == "" { + f.Website = normalizeRepoURL(val) + } + if strings.EqualFold(key, "repository") { + repoURL := normalizeRepoURL(val) + if repoURL != "" && (f.Website == "" || looksInstallScriptURL(f.Website)) { + f.Website = repoURL + } + } + case "[project.scripts]": + if f.PythonConsoleScript == "" && key != "" { + f.PythonConsoleScript = sanitizeName(key) + } + } + } +} + +func determineSuggestedSourceGenerator(repoDir string, f *RepoFacts) { + if f == nil { + return + } + + switch f.PrimaryType { + case "go": + inspectGoModuleForBaseline(repoDir, f) + case "rust": + if f.HasCargoToml { + f.SuggestedSourceGenerator = "cargohome" + f.SuggestedSourceGeneratorSafe = true + f.SuggestedSourceGeneratorReason = "Cargo manifest detected" + } + case "node": + if f.HasPackageJSON { + f.SuggestedSourceGenerator = "node-mod" + f.SuggestedSourceGeneratorSafe = true + f.SuggestedSourceGeneratorReason = "package.json detected" + } + case "python": + if f.HasPyProject || f.HasRequirements || f.HasSetupPy { + f.SuggestedSourceGenerator = "pip" + f.SuggestedSourceGeneratorSafe = true + f.SuggestedSourceGeneratorReason = "Python packaging metadata detected" + } + } +} + +func inspectGoModuleForBaseline(repoDir string, f *RepoFacts) { + f.SuggestedSourceGenerator = "gomod" + f.SuggestedSourceGeneratorSafe = false + f.SuggestedSourceGeneratorReason = "" + f.GoModuleDirective = "" + f.GoModuleToolchain = "" + f.GoModuleHasToolBlock = false + + b, err := os.ReadFile(filepath.Join(repoDir, "go.mod")) + if err != nil { + f.SuggestedSourceGeneratorReason = "go.mod could not be read" + return + } + + f.SuggestedSourceGeneratorSafe = true + f.SuggestedSourceGeneratorReason = "go.mod uses a conservative language-version shape" + + for _, raw := range strings.Split(string(b), "\n") { + line := strings.TrimSpace(raw) + if line == "" || strings.HasPrefix(line, "//") { + continue + } + + switch { + case strings.HasPrefix(line, "go "): + fields := strings.Fields(line) + if len(fields) >= 2 { + f.GoModuleDirective = strings.TrimSpace(fields[1]) + if !isConservativeGoLanguageVersion(f.GoModuleDirective) { + f.SuggestedSourceGeneratorSafe = false + f.SuggestedSourceGeneratorReason = fmt.Sprintf("go.mod uses non-conservative go directive %q", f.GoModuleDirective) + } + } + + case strings.HasPrefix(line, "toolchain "): + f.GoModuleToolchain = strings.TrimSpace(strings.TrimPrefix(line, "toolchain ")) + f.SuggestedSourceGeneratorSafe = false + f.SuggestedSourceGeneratorReason = "go.mod uses a toolchain directive" + + case line == "tool (" || strings.HasPrefix(line, "tool ("): + f.GoModuleHasToolBlock = true + f.SuggestedSourceGeneratorSafe = false + f.SuggestedSourceGeneratorReason = "go.mod uses a tool block" + } + } +} + +func normalizeGoDirectiveVersion(v string) string { + v = strings.TrimSpace(v) + if v == "" { + return "" + } + + parts := strings.Split(v, ".") + if len(parts) >= 2 { + return parts[0] + "." + parts[1] + } + return v +} + +func hasPatchGoDirective(v string) bool { + v = strings.TrimSpace(v) + if v == "" { + return false + } + return len(strings.Split(v, ".")) >= 3 +} + +func normalizeGoToolchainVersion(v string) string { + v = strings.TrimSpace(v) + v = strings.TrimPrefix(v, "go") + return v +} + +func chooseManagedGoToolchainVersion(f *RepoFacts) string { + if f == nil { + return "" + } + + if v := normalizeGoToolchainVersion(f.GoModuleToolchain); v != "" { + return v + } + + if v := normalizeGoDirectiveVersion(f.GoModuleDirective); v != "" { + return v + ".0" + } + + return "" +} + +func isConservativeGoLanguageVersion(v string) bool { + v = strings.TrimSpace(v) + if v == "" { + return false + } + return regexp.MustCompile(`^\d+\.\d+$`).MatchString(v) +} + +func parseGoModulePath(repoDir string) string { + b, err := os.ReadFile(filepath.Join(repoDir, "go.mod")) + if err != nil { + return "" + } + for _, raw := range strings.Split(string(b), "\n") { + line := strings.TrimSpace(raw) + if strings.HasPrefix(line, "module ") { + return strings.TrimSpace(strings.TrimPrefix(line, "module ")) + } + } + return "" +} + +func moduleLeafName(modPath string) string { + modPath = strings.TrimSpace(modPath) + if modPath == "" { + return "" + } + parts := strings.Split(modPath, "/") + return sanitizeName(parts[len(parts)-1]) +} + +func modulePathToRepoURL(modPath string) string { + modPath = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(modPath, "https://"), ".git")) + modPath = strings.TrimPrefix(modPath, "http://") + modPath = strings.TrimPrefix(modPath, "git+") + if modPath == "" { + return "" + } + + parts := strings.Split(modPath, "/") + if len(parts) < 3 { + return "" + } + + host := parts[0] + switch host { + case "github.com", "gitlab.com", "bitbucket.org": + return "https://" + strings.Join(parts[:3], "/") + default: + return "" + } +} + +// detectGoMainCandidates scans the repository for Go main packages. +// +// It checks the repository root (.) plus the following subdirectory layouts +// that are common in real-world Go projects: +// +// cmd// — standard multi-binary layout +// app// — alternative multi-binary layout used by some projects +// bin// — less common but found in several OSS projects +// +// Directories whose names match internalToolName are excluded — they are +// typically build helpers (gen-*, protoc-gen-*, stringer, wire, etc.) and +// should not appear as installable binaries in the dalec spec. +func detectGoMainCandidates(repoDir string) []string { + var candidates []string + + hasMainPackage := func(dir string) bool { + entries, err := os.ReadDir(dir) + if err != nil { + return false + } + + sawPackageMain := false + sawFuncMain := false + + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { + continue + } + + b, err := os.ReadFile(filepath.Join(dir, name)) + if err != nil { + continue + } + s := string(b) + + if strings.Contains(s, "package main") { + sawPackageMain = true + } + if strings.Contains(s, "func main(") { + sawFuncMain = true + } + } + + return sawPackageMain && sawFuncMain + } + + // Check root first. + if hasMainPackage(repoDir) { + candidates = append(candidates, ".") + } + + // Scan common multi-binary parent directories. + for _, cmdParent := range []string{"cmd", "app", "bin"} { + parent := filepath.Join(repoDir, cmdParent) + entries, err := os.ReadDir(parent) + if err != nil { + continue + } + for _, e := range entries { + if !e.IsDir() { + continue + } + // Skip known build-time tool directories — they are not installable + // package artifacts and should not appear in the dalec spec. + if internalToolName(e.Name()) { + continue + } + subdir := filepath.Join(parent, e.Name()) + if hasMainPackage(subdir) { + candidates = append(candidates, filepath.ToSlash(filepath.Join(cmdParent, e.Name()))) + } + } + } + + sort.Strings(candidates) + return candidates +} + +// detectGoVersionVarPath attempts to locate the Go variable used to hold the +// version string so it can be injected at link time via -ldflags. +// +// It inspects common patterns: +// +// version/version.go — dedicated version package +// internal/version/*.go — internal version package +// pkg/version/*.go — public version package +// cmd//version.go — per-binary version file +// /*.go — version var in root package +// +// Returns a fully-qualified path like "github.com/foo/bar/version.Version" or +// "main.version", or "" if nothing was found. +func detectGoVersionVarPath(repoDir string, modulePath string) string { + type candidate struct { + relDir string + pkg string // last element used to build the import path + } + + candidates := []candidate{ + {"version", "version"}, + {"internal/version", "version"}, + {"pkg/version", "version"}, + {"", ""}, // root package — uses "main" + } + + // Also check cmd// for per-binary version files. + cmdDir := filepath.Join(repoDir, "cmd") + if entries, err := os.ReadDir(cmdDir); err == nil { + for _, e := range entries { + if e.IsDir() { + candidates = append(candidates, candidate{ + relDir: filepath.ToSlash(filepath.Join("cmd", e.Name())), + pkg: "main", + }) + } + } + } + + for _, c := range candidates { + dir := repoDir + if c.relDir != "" { + dir = filepath.Join(repoDir, c.relDir) + } + + entries, err := os.ReadDir(dir) + if err != nil { + continue + } + + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".go") || strings.HasSuffix(e.Name(), "_test.go") { + continue + } + b, err := os.ReadFile(filepath.Join(dir, e.Name())) + if err != nil { + continue + } + s := string(b) + + // Look for exported or unexported version variable declarations. + for _, varName := range []string{"Version", "version", "AppVersion"} { + if strings.Contains(s, "var "+varName+" ") || strings.Contains(s, "var "+varName+"=") { + if c.relDir == "" || c.pkg == "main" { + return "main." + varName + } + importPath := modulePath + "/" + c.relDir + return importPath + "." + varName + } + } + } + } + + return "" +} + +func firstExistingTextFile(repoDir string, names []string, maxBytes int) (string, string, bool) { + for _, name := range names { + b, err := os.ReadFile(filepath.Join(repoDir, name)) + if err != nil { + continue + } + if maxBytes > 0 && len(b) > maxBytes { + b = b[:maxBytes] + } + return name, string(b), true + } + return "", "", false +} + +func isBadgeLikeReadmeLine(line string) bool { + line = strings.TrimSpace(line) + if line == "" { + return false + } + + if strings.HasPrefix(line, "[![") || strings.HasPrefix(line, "![") { + return true + } + + badgeHints := []string{ + "shields.io", + "goreportcard.com", + "/actions/workflows/", + "/actions/runs/", + "badge.svg", + "img.shields.io", + } + for _, h := range badgeHints { + if strings.Contains(line, h) { + return true + } + } + return false +} + +func firstReadmeSentence(s string) string { + inCodeFence := false + for _, raw := range strings.Split(s, "\n") { + line := strings.TrimSpace(raw) + + if strings.HasPrefix(line, "```") { + inCodeFence = !inCodeFence + continue + } + if inCodeFence { + continue + } + + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if isBadgeLikeReadmeLine(line) { + continue + } + if strings.HasPrefix(line, "|") || strings.HasPrefix(line, "---") { + continue + } + // skip HTML tags + if strings.HasPrefix(line, "<") { + continue + } + line = normalizeReadmeLine(line) + if line == "" { + continue + } + if len(line) < 20 { + continue + } + + if len(line) > 220 { + line = truncateAtWord(line, 220) + } + if !strings.HasSuffix(line, ".") { + line += "." + } + return line + } + return "" +} + +func firstUsefulReadmeURL(s string) string { + for _, raw := range strings.Split(s, "\n") { + line := strings.TrimSpace(raw) + if line == "" { + continue + } + if isBadgeLikeReadmeLine(line) { + continue + } + for _, u := range allURLs(line) { + if isUsefulProjectURL(u) { + return normalizeRepoURL(u) + } + } + } + return "" +} + +func allURLs(s string) []string { + var out []string + for _, tok := range strings.Fields(s) { + tok = strings.Trim(tok, "()<>{}[],'\".") + if strings.HasPrefix(tok, "https://") || strings.HasPrefix(tok, "http://") { + out = append(out, tok) + } + } + return out +} + +func isUsefulProjectURL(u string) bool { + lu := strings.ToLower(strings.TrimSpace(u)) + if lu == "" { + return false + } + + bad := []string{ + "shields.io", + "img.shields.io", + "goreportcard.com", + "/actions/workflows/", + "/actions/runs/", + "badge.svg", + ".svg", + ".png", + ".jpg", + ".jpeg", + ".gif", + "macports", + "homebrew", + "chocolatey", + "winget", + "pkg.go.dev", + "godoc.org", + "msys2.org", + "repology.org", + "snapcraft.io", + "/blob/", + } + for _, x := range bad { + if strings.Contains(lu, x) { + return false + } + } + return strings.HasPrefix(lu, "https://") || strings.HasPrefix(lu, "http://") +} + +func normalizeRepoURL(s string) string { + s = strings.TrimSpace(s) + s = strings.TrimPrefix(s, "git+") + s = strings.TrimSuffix(s, ".git") + return s +} + +func normalizeReadmeLine(line string) string { + line = strings.TrimSpace(line) + line = strings.TrimPrefix(line, "- ") + line = strings.TrimPrefix(line, "* ") + line = strings.TrimPrefix(line, "+ ") + line = strings.TrimPrefix(line, "> ") + line = strings.TrimSpace(line) + + line = strings.ReplaceAll(line, "`", "") + line = strings.ReplaceAll(line, "**", "") + line = strings.ReplaceAll(line, "__", "") + + re := regexp.MustCompile(`\[(.*?)\]\((.*?)\)`) + line = re.ReplaceAllString(line, "$1") + + line = strings.TrimSpace(strings.Trim(line, "*_-'\"")) + line = collapseSpaces(line) + return line +} + +func normalizeDetectedDescription(s string) string { + s = normalizeReadmeLine(s) + if looksTodo(s) { + return s + } + if len(s) > 220 { + s = truncateAtWord(s, 220) + } + if s != "" && !strings.HasSuffix(s, ".") { + s += "." + } + return s +} + +func detectLicenseFromText(s string) string { + upper := strings.ToUpper(s) + switch { + case strings.Contains(upper, "APACHE LICENSE") && strings.Contains(upper, "VERSION 2.0"): + return "Apache-2.0" + case strings.Contains(upper, "MIT LICENSE"): + return "MIT" + case strings.Contains(upper, "GNU GENERAL PUBLIC LICENSE"): + return "GPL" + case strings.Contains(upper, "BSD"): + return "BSD" + default: + return "" + } +} + +func cleanTOMLLine(line string) string { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + return "" + } + if idx := strings.Index(line, "#"); idx >= 0 { + line = strings.TrimSpace(line[:idx]) + } + return line +} + +func parseTOMLKV(line string) (key, value string, ok bool) { + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + return "", "", false + } + key = strings.TrimSpace(parts[0]) + value = strings.TrimSpace(parts[1]) + value = strings.Trim(value, "\"'") + return key, value, key != "" +} + +func truncateAtWord(s string, maxLen int) string { + if maxLen <= 0 || len(s) <= maxLen { + return s + } + cut := s[:maxLen] + if idx := strings.LastIndex(cut, " "); idx >= 40 { + cut = cut[:idx] + } + return strings.TrimSpace(cut) +} + +func collapseSpaces(s string) string { + return strings.Join(strings.Fields(s), " ") +} + +func sanitizeName(s string) string { + s = strings.TrimSpace(strings.ToLower(s)) + s = strings.TrimPrefix(s, "@") + s = strings.ReplaceAll(s, "/", "-") + s = strings.ReplaceAll(s, "_", "-") + s = strings.ReplaceAll(s, " ", "-") + for strings.Contains(s, "--") { + s = strings.ReplaceAll(s, "--", "-") + } + s = strings.Trim(s, "-") + if s == "" { + return "unknown" + } + return s +} + +func detectVersionHint(repoDir string) string { + for _, name := range []string{"VERSION", "version", "version.txt", ".version"} { + b, err := os.ReadFile(filepath.Join(repoDir, name)) + if err != nil { + continue + } + if v := normalizeDetectedVersion(string(b)); v != "" { + return v + } + } + + if v := detectVersionFromGit(repoDir); v != "" { + return v + } + + return "" +} + +func detectVersionFromGit(repoDir string) string { + cmd := exec.Command("git", "-C", repoDir, "describe", "--tags", "--abbrev=0") + out, err := cmd.Output() + if err != nil { + return "" + } + return normalizeDetectedVersion(string(out)) +} + +func normalizeDetectedVersion(s string) string { + s = strings.TrimSpace(s) + if s == "" { + return "" + } + if strings.Contains(s, "\n") { + s = strings.TrimSpace(strings.SplitN(s, "\n", 2)[0]) + } + s = strings.TrimPrefix(s, "v") + s = strings.TrimPrefix(s, "V") + if looksVersionLike(s) { + return s + } + return "" +} + +func looksVersionLike(s string) bool { + s = strings.TrimSpace(s) + if s == "" { + return false + } + re := regexp.MustCompile(`^[0-9]+(\.[0-9]+){1,3}([\-+][A-Za-z0-9._-]+)?([\-+][A-Za-z0-9._-]+)?$`) + return re.MatchString(s) +} diff --git a/internal/specgen/detect_test.go b/internal/specgen/detect_test.go new file mode 100644 index 000000000..8d47e802b --- /dev/null +++ b/internal/specgen/detect_test.go @@ -0,0 +1,107 @@ +package specgen + +import ( + "os" + "path/filepath" + "testing" +) + +func TestDetectRepo_ForcedTypeStillEnrichesPackageJSON(t *testing.T) { + dir := t.TempDir() + + err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{ + "name": "@example/hello-cli", + "version": "1.2.3", + "license": "MIT", + "description": "Hello CLI", + "homepage": "https://example.com/hello", + "scripts": { "build": "tsc" }, + "main": "dist/index.js", + "bin": { "hello": "bin/hello.js" } +}`), 0o644) + if err != nil { + t.Fatal(err) + } + + f, warnings, err := DetectRepo(dir, "node") + if err != nil { + t.Fatal(err) + } + _ = warnings + + if f.PrimaryType != "node" { + t.Fatalf("expected forced node type, got %q", f.PrimaryType) + } + if f.Name != "example-hello-cli" { + t.Fatalf("unexpected name: %q", f.Name) + } + if f.Version != "1.2.3" { + t.Fatalf("unexpected version: %q", f.Version) + } + if f.License != "MIT" { + t.Fatalf("unexpected license: %q", f.License) + } + if f.Description != "Hello CLI" { + t.Fatalf("unexpected description: %q", f.Description) + } + if f.Website != "https://example.com/hello" { + t.Fatalf("unexpected website: %q", f.Website) + } + if !f.NodeHasBuild { + t.Fatalf("expected NodeHasBuild=true") + } + if f.NodeMain != "dist/index.js" { + t.Fatalf("unexpected NodeMain: %q", f.NodeMain) + } + if f.NodeBinName != "hello" { + t.Fatalf("unexpected NodeBinName: %q", f.NodeBinName) + } + if !f.SuggestedSourceGeneratorSafe || f.SuggestedSourceGenerator != "node-mod" { + t.Fatalf("expected safe node source generator, got generator=%q safe=%v reason=%q", f.SuggestedSourceGenerator, f.SuggestedSourceGeneratorSafe, f.SuggestedSourceGeneratorReason) + } +} + +func TestDetectRepo_GoSourceGeneratorSafety_SafeDirective(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module example.com/foo\ngo 1.25\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main\nfunc main(){}\n"), 0o644); err != nil { + t.Fatal(err) + } + + f, _, err := DetectRepo(dir, "") + if err != nil { + t.Fatal(err) + } + if f.SuggestedSourceGenerator != "gomod" { + t.Fatalf("expected gomod suggestion, got %q", f.SuggestedSourceGenerator) + } + if !f.SuggestedSourceGeneratorSafe { + t.Fatalf("expected gomod suggestion to be safe, reason=%q", f.SuggestedSourceGeneratorReason) + } +} + +func TestDetectRepo_GoSourceGeneratorSafety_UnsafeDirective(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module example.com/foo\ngo 1.25.0\ntoolchain go1.25.5\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main\nfunc main(){}\n"), 0o644); err != nil { + t.Fatal(err) + } + + f, _, err := DetectRepo(dir, "") + if err != nil { + t.Fatal(err) + } + if f.SuggestedSourceGenerator != "gomod" { + t.Fatalf("expected gomod suggestion, got %q", f.SuggestedSourceGenerator) + } + if f.SuggestedSourceGeneratorSafe { + t.Fatalf("expected gomod suggestion to be unsafe") + } + if f.SuggestedSourceGeneratorReason == "" { + t.Fatalf("expected unsafe reason to be populated") + } +} diff --git a/internal/specgen/fsutil.go b/internal/specgen/fsutil.go new file mode 100644 index 000000000..76cca0efe --- /dev/null +++ b/internal/specgen/fsutil.go @@ -0,0 +1,336 @@ +package specgen + +import ( + "io/fs" + "os" + "path/filepath" + "sort" + "strings" +) + +const defaultPerFileContextLimit = 32 * 1024 + +type scoredCandidate struct { + Path string + Score int +} + +func readSmallFile(repoDir, rel string, limit int) ([]byte, error) { + if limit <= 0 { + return nil, nil + } + p := filepath.Join(repoDir, rel) + b, err := os.ReadFile(p) + if err != nil { + return nil, err + } + if len(b) > limit { + return b[:limit], nil + } + return b, nil +} + +func listFiles(repoDir, relDir string, max int) ([]string, error) { + dir := filepath.Join(repoDir, relDir) + entries, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + names := make([]string, 0, len(entries)) + for _, e := range entries { + if e.IsDir() { + continue + } + names = append(names, e.Name()) + } + sort.Strings(names) + var out []string + for _, n := range names { + if max > 0 && len(out) >= max { + break + } + out = append(out, filepath.ToSlash(filepath.Join(relDir, n))) + } + return out, nil +} + +func CollectRepoContextFiles(facts *RepoFacts, analysis *Analysis, unresolved []UnresolvedItem, maxFiles, maxBytes int) ([]AIFile, []string) { + if maxFiles <= 0 { + maxFiles = 60 + } + if maxBytes <= 0 { + maxBytes = 300 * 1024 + } + var warnings []string + if facts == nil { + return nil, []string{"AI context had no repo facts; file selection skipped"} + } + repoDir := facts.RepoDir + candidates := map[string]int{} + addCandidate := func(rel string, score int) { + rel = filepath.ToSlash(strings.TrimSpace(rel)) + if rel == "" { + return + } + st, err := os.Stat(filepath.Join(repoDir, rel)) + if err != nil || st.IsDir() { + return + } + if cur, ok := candidates[rel]; !ok || score > cur { + candidates[rel] = score + } + } + + for _, item := range []struct { + path string + score int + }{ + {"Makefile", 150}, {"GNUmakefile", 150}, {"makefile", 150}, + {"Taskfile.yml", 145}, {"Taskfile.yaml", 145}, {"justfile", 145}, + {"azure-pipelines.yml", 142}, {".gitlab-ci.yml", 141}, {".cirrus.yml", 141}, + {"README.md", 138}, {"README.rst", 138}, {"README.txt", 138}, + {"LICENSE", 136}, {"LICENSE.txt", 136}, {"COPYING", 136}, + {"Dockerfile", 132}, {"Containerfile", 132}, + {"go.mod", 134}, {"go.sum", 122}, {"Cargo.toml", 134}, {"Cargo.lock", 122}, + {"package.json", 134}, {"package-lock.json", 122}, {"pnpm-lock.yaml", 122}, {"yarn.lock", 122}, + {"pyproject.toml", 134}, {"setup.py", 130}, {"requirements.txt", 128}, + } { + addCandidate(item.path, item.score) + } + addCandidate(facts.ReadmePath, 139) + addCandidate(facts.LicensePath, 137) + + if analysis != nil { + for _, ev := range analysis.Evidence { + addCandidate(ev.Source, 143) + } + for _, p := range analysis.ManpagePaths { + addCandidate(p, 120) + } + for _, p := range analysis.ConfigPaths { + addCandidate(p, 122) + } + for _, svc := range analysis.Services { + addCandidate(svc.UnitPath, 126) + addCandidate(svc.ConfigPath, 121) + } + for _, rel := range facts.GoMainCandidates { + if rel == "." { + addCandidate("main.go", 124) + } else { + addCandidate(filepath.ToSlash(filepath.Join(rel, "main.go")), 124) + } + } + if facts.NodeMain != "" { + addCandidate(facts.NodeMain, 122) + } + if facts.PythonModuleName != "" { + addCandidate(filepath.ToSlash(strings.ReplaceAll(facts.PythonModuleName, ".", "/")+".py"), 120) + } + } + + for _, item := range unresolved { + switch item.Code { + case "build.multiple_drivers", "dependencies.build", "build.steps", "repo.unknown_type", "artifacts.make_output_unknown", "artifacts.multi_bin_selection", "image.entrypoint": + addCandidate("Makefile", 150) + addCandidate("README.md", 138) + addCandidate("azure-pipelines.yml", 142) + } + } + + for _, dir := range []struct { + name string + max int + score int + }{ + {".github/workflows", 20, 143}, {"docs", 20, 114}, {"cmd", 30, 118}, + {"scripts", 24, 120}, {"hack", 20, 111}, {"build", 20, 112}, {"pkg", 20, 96}, + {"internal", 20, 96}, {"src", 20, 96}, {"packaging", 24, 126}, {"deploy", 20, 122}, + {"dist", 20, 122}, {"contrib", 20, 116}, {"systemd", 12, 127}, {"man", 20, 124}, + } { + walkImportantDir(repoDir, candidates, dir.name, dir.max, dir.score) + } + + var ordered []scoredCandidate + for p, s := range candidates { + ordered = append(ordered, scoredCandidate{Path: p, Score: s}) + } + sort.Slice(ordered, func(i, j int) bool { + if ordered[i].Score == ordered[j].Score { + return ordered[i].Path < ordered[j].Path + } + return ordered[i].Score > ordered[j].Score + }) + + var out []AIFile + used := 0 + for _, c := range ordered { + if len(out) >= maxFiles || used >= maxBytes { + break + } + perFileLimit := defaultPerFileContextLimit + if remaining := maxBytes - used; remaining < perFileLimit { + perFileLimit = remaining + } + if perFileLimit <= 0 { + break + } + b, err := readSmallFile(repoDir, c.Path, perFileLimit*3) + if err != nil || len(b) == 0 { + continue + } + content := strings.TrimSpace(shrinkContextFile(c.Path, string(b), perFileLimit)) + if content == "" { + continue + } + used += len(content) + out = append(out, AIFile{Path: c.Path, Content: content}) + } + if len(out) == 0 { + warnings = append(warnings, "AI context had no readable files (repo may be empty or selection too strict)") + } + return out, warnings +} + +func walkImportantDir(repoDir string, out map[string]int, relDir string, max int, score int) { + root := filepath.Join(repoDir, relDir) + if st, err := os.Stat(root); err != nil || !st.IsDir() { + return + } + interestingBase := map[string]struct{}{"Makefile": {}, "GNUmakefile": {}, "makefile": {}, "Taskfile.yml": {}, "Taskfile.yaml": {}, "justfile": {}, "Dockerfile": {}, "Containerfile": {}, "package.json": {}, "Cargo.toml": {}, "go.mod": {}, "pyproject.toml": {}, "README.md": {}, "README.rst": {}, "README.txt": {}} + interestingExts := []string{".go", ".rs", ".py", ".js", ".mjs", ".cjs", ".ts", ".tsx", ".sh", ".ps1", ".service", ".yml", ".yaml", ".json", ".toml", ".mk", ".md", ".rst", ".txt", ".1", ".5", ".8", ".conf"} + count := 0 + _ = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return nil + } + if count >= max { + return fs.SkipAll + } + if d.IsDir() { + return nil + } + rel, err := filepath.Rel(repoDir, path) + if err != nil { + return nil + } + rel = filepath.ToSlash(rel) + base := filepath.Base(path) + if _, ok := interestingBase[base]; ok || hasInterestingExt(path, interestingExts) { + if cur, ok := out[rel]; !ok || score > cur { + out[rel] = score + } + count++ + } + return nil + }) +} + +func hasInterestingExt(path string, exts []string) bool { + low := strings.ToLower(path) + for _, ext := range exts { + if strings.HasSuffix(low, strings.ToLower(ext)) { + return true + } + } + return false +} + +func shrinkContextFile(rel, content string, limit int) string { + if limit <= 0 { + return "" + } + content = strings.ReplaceAll(content, "\r\n", "\n") + content = strings.ReplaceAll(content, "\r", "\n") + content = strings.TrimSpace(content) + if content == "" || len(content) <= limit { + return content + } + lowRel := strings.ToLower(filepath.ToSlash(rel)) + base := filepath.Base(lowRel) + switch { + case strings.Contains(lowRel, ".github/workflows/"), base == "azure-pipelines.yml", base == ".gitlab-ci.yml", base == ".cirrus.yml", base == "makefile", base == "gnumakefile", base == "taskfile.yml", base == "taskfile.yaml", base == "justfile", base == "dockerfile", base == "containerfile", strings.HasSuffix(base, ".service"): + return headTail(content, limit) + case strings.HasSuffix(base, ".md"), strings.HasSuffix(base, ".rst"), strings.HasSuffix(base, ".txt"), strings.HasPrefix(base, "readme"), base == "license", base == "license.txt", base == "copying": + return extractRelevantText(content, limit, []string{"build", "install", "usage", "run", "quickstart", "compile", "binary", "output", "entrypoint", "command", "test", "systemd", "service"}) + default: + return headTail(content, limit) + } +} + +func extractRelevantText(content string, limit int, keywords []string) string { + lines := strings.Split(content, "\n") + selected := map[int]struct{}{} + for i := 0; i < len(lines) && i < 80; i++ { + selected[i] = struct{}{} + } + for i, line := range lines { + if !containsAnyFold(line, keywords) { + continue + } + start := max(0, i-2) + end := min(len(lines)-1, i+4) + for j := start; j <= end; j++ { + selected[j] = struct{}{} + } + } + var idxs []int + for i := range selected { + idxs = append(idxs, i) + } + sort.Ints(idxs) + var b strings.Builder + last := -2 + for _, i := range idxs { + if last >= 0 && i > last+1 { + b.WriteString("\n...\n") + } + b.WriteString(lines[i]) + b.WriteString("\n") + last = i + } + return headTail(strings.TrimSpace(b.String()), limit) +} + +func headTail(content string, limit int) string { + if limit <= 0 || len(content) <= limit { + return content + } + if limit < 64 { + return content[:limit] + } + head := (limit * 2) / 3 + tail := limit - head - len("\n...\n") + if tail < 16 { + tail = 16 + if head > limit-tail-len("\n...\n") { + head = limit - tail - len("\n...\n") + } + } + if head+tail+len("\n...\n") >= len(content) { + return content[:limit] + } + return content[:head] + "\n...\n" + content[len(content)-tail:] +} + +func containsAnyFold(s string, needles []string) bool { + ls := strings.ToLower(s) + for _, n := range needles { + if strings.Contains(ls, strings.ToLower(n)) { + return true + } + } + return false +} +func min(a, b int) int { + if a < b { + return a + } + return b +} +func max(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/internal/specgen/gaps.go b/internal/specgen/gaps.go new file mode 100644 index 000000000..6dfdbe39b --- /dev/null +++ b/internal/specgen/gaps.go @@ -0,0 +1,312 @@ +package specgen + +import ( + "fmt" + "strings" + + "github.com/project-dalec/dalec" +) + +func FindSpecGaps(a *Analysis, spec *dalec.Spec) []UnresolvedItem { + var out []UnresolvedItem + + if spec == nil { + return []UnresolvedItem{{ + Code: "spec.nil", + Message: "generated spec is nil", + Severity: "high", + }} + } + + if strings.TrimSpace(spec.Name) == "" { + out = append(out, UnresolvedItem{ + Code: "spec.name", + Message: "spec name is empty", + Severity: "high", + }) + } + if strings.TrimSpace(spec.Version) == "" { + out = append(out, UnresolvedItem{ + Code: "spec.version", + Message: "spec version is empty", + Severity: "high", + }) + } + if strings.TrimSpace(spec.Revision) == "" { + out = append(out, UnresolvedItem{ + Code: "spec.revision", + Message: "spec revision is empty", + Severity: "high", + }) + } + if len(spec.Sources) == 0 { + out = append(out, UnresolvedItem{ + Code: "spec.sources", + Message: "spec has no sources", + Severity: "high", + }) + } + + if looksTodo(spec.Description) { + out = append(out, UnresolvedItem{ + Code: "description", + Message: "spec description is missing or placeholder", + Severity: "low", + }) + } + if looksTodo(spec.License) { + out = append(out, UnresolvedItem{ + Code: "license", + Message: "spec license is missing or placeholder", + Severity: "medium", + }) + } + if strings.TrimSpace(spec.Website) == "" { + out = append(out, UnresolvedItem{ + Code: "website", + Message: "spec website is missing", + Severity: "low", + }) + } + + if shouldExpectBuildSteps(a) { + if len(spec.Build.Steps) == 0 { + out = append(out, UnresolvedItem{ + Code: "build.steps", + Message: "build steps are missing for a baseline that appears to require a build", + Severity: "high", + }) + } else { + for i, s := range spec.Build.Steps { + if strings.TrimSpace(s.Command) == "" { + out = append(out, UnresolvedItem{ + Code: "build.empty_step", + Message: fmt.Sprintf("build step %d is empty", i), + Severity: "high", + }) + } + } + } + } + + if shouldExpectBinaryArtifacts(a) && len(spec.Artifacts.Binaries) == 0 { + out = append(out, UnresolvedItem{ + Code: "artifacts.binaries", + Message: "analysis suggests binary outputs but no binary artifacts were emitted", + Severity: "high", + }) + } + if shouldExpectConfigArtifacts(a) && len(spec.Artifacts.ConfigFiles) == 0 { + out = append(out, UnresolvedItem{ + Code: "artifacts.config", + Message: "analysis suggests config files but no config artifacts were emitted", + Severity: "medium", + }) + } + if shouldExpectManpages(a) && len(spec.Artifacts.Manpages) == 0 { + out = append(out, UnresolvedItem{ + Code: "artifacts.manpages", + Message: "analysis suggests manpages but none were emitted", + Severity: "medium", + }) + } + if shouldExpectLibexec(a) && len(spec.Artifacts.Libexec) == 0 { + out = append(out, UnresolvedItem{ + Code: "artifacts.libexec", + Message: "analysis suggests libexec files but none were emitted", + Severity: "medium", + }) + } + if shouldExpectSystemd(a) { + if spec.Artifacts.Systemd == nil || spec.Artifacts.Systemd.IsEmpty() { + out = append(out, UnresolvedItem{ + Code: "artifacts.systemd", + Message: "analysis suggests systemd units but none were emitted", + Severity: "medium", + }) + } + } + + return dedupeUnresolved(out) +} + +func shouldExpectBinaryArtifacts(a *Analysis) bool { + if a == nil { + return false + } + for _, art := range a.Artifacts { + if strings.TrimSpace(art.Kind) == "binary" { + return true + } + } + return false +} + +func shouldExpectConfigArtifacts(a *Analysis) bool { + if a == nil { + return false + } + for _, art := range a.Artifacts { + if strings.TrimSpace(art.Kind) == "config" { + return true + } + } + return false +} + +func shouldExpectManpages(a *Analysis) bool { + if a == nil { + return false + } + for _, art := range a.Artifacts { + if strings.TrimSpace(art.Kind) == "manpage" { + return true + } + } + return false +} + +func shouldExpectLibexec(a *Analysis) bool { + if a == nil { + return false + } + for _, art := range a.Artifacts { + if strings.TrimSpace(art.Kind) == "libexec" { + return true + } + } + return false +} + +func shouldExpectSystemd(a *Analysis) bool { + if a == nil { + return false + } + for _, art := range a.Artifacts { + if strings.TrimSpace(art.Kind) == "systemd" { + return true + } + } + return false +} + +func shouldExpectBuildSteps(a *Analysis) bool { + if a == nil { + return true + } + + switch a.SelectedStrategy { + case "generic-placeholder", "container-assembly": + return false + } + + if looksContainerOnlyAnalysis(a) { + return false + } + + return true +} + +func shouldExpectBuildDeps(a *Analysis) bool { + if a == nil { + return true + } + + switch a.SelectedStrategy { + case "generic-placeholder", "container-assembly": + return false + default: + return !looksContainerOnlyAnalysis(a) + } +} + +func shouldExpectArtifacts(a *Analysis) bool { + if a == nil { + return true + } + + switch a.SelectedStrategy { + case "generic-placeholder": + return false + default: + return true + } +} + +func shouldExpectTests(a *Analysis) bool { + if a == nil { + return true + } + + if looksContainerOnlyAnalysis(a) { + return false + } + + if a.Runtime.Kind != "unknown" && a.Runtime.Kind != "library" { + return true + } + + switch a.SelectedStrategy { + case "go-simple", "go-make", "go-multi-bin", "go-make-multi-bin", "rust-simple", "rust-workspace", "python-wheel", "python-requirements", "node-npm-app", "node-yarn-app", "node-pnpm-app": + return true + default: + return false + } +} + +func looksContainerOnlyAnalysis(a *Analysis) bool { + if a == nil { + return false + } + + if a.SelectedStrategy == "container-assembly" { + return true + } + + if a.Facts == nil { + return false + } + + hasLanguageProject := a.Facts.HasGoMod || + a.Facts.HasCargoToml || + a.Facts.HasPackageJSON || + a.Facts.HasPyProject || + a.Facts.HasRequirements || + a.Facts.HasSetupPy + + if (a.Facts.HasDockerfile || a.Facts.HasContainerfile) && !hasLanguageProject { + return true + } + + return false +} + +func looksPlaceholderBuildCommand(cmd string) bool { + low := strings.ToLower(strings.TrimSpace(cmd)) + return strings.Contains(low, "todo: add build steps") || + strings.Contains(low, "placeholder build") || + strings.Contains(low, "echo 'container-only intent") || + strings.Contains(low, `echo "container-only intent`) +} + +func countArtifacts(a dalec.Artifacts) int { + return len(a.Binaries) + + len(a.Docs) + + len(a.DataDirs) + + len(a.Manpages) + + len(a.ConfigFiles) +} + +func dedupeUnresolved(in []UnresolvedItem) []UnresolvedItem { + seen := map[string]struct{}{} + out := make([]UnresolvedItem, 0, len(in)) + for _, item := range in { + key := item.Code + "|" + item.Message + "|" + item.Severity + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, item) + } + return out +} diff --git a/internal/specgen/helpers.go b/internal/specgen/helpers.go new file mode 100644 index 000000000..6dac4e33e --- /dev/null +++ b/internal/specgen/helpers.go @@ -0,0 +1,170 @@ +package specgen + +import ( + "os" + "strings" +) + +func firstNonEmpty(vals ...string) string { + for _, v := range vals { + if strings.TrimSpace(v) != "" { + return v + } + } + return "" +} + +func looksSemanticMajorOnly(s string) bool { + s = strings.TrimSpace(strings.ToLower(s)) + if len(s) < 2 || s[0] != 'v' { + return false + } + for _, ch := range s[1:] { + if ch < '0' || ch > '9' { + return false + } + } + return true +} + +func fileContains(path string, needle string) bool { + b, err := os.ReadFile(path) + if err != nil { + return false + } + return strings.Contains(string(b), needle) +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +func hasDir(path string) bool { + st, err := os.Stat(path) + return err == nil && st.IsDir() +} + +func boolToString(v bool) string { + if v { + return "1" + } + return "" +} + +func nonEmptyCount(vals ...string) []string { + var out []string + for _, v := range vals { + if strings.TrimSpace(v) != "" { + out = append(out, v) + } + } + return out +} + +func looksTodo(s string) bool { + s = strings.TrimSpace(strings.ToLower(s)) + return s == "" || s == "todo" || strings.Contains(s, "todo:") +} + +// selectStrategy is the canonical single source of truth for choosing a build +// strategy from repo facts. chooseBuildStyle in plan_deterministic.go delegates +// here rather than duplicating this switch. +func selectStrategy(f *RepoFacts, a *Analysis) string { + // Container-only: dockerfile/containerfile present but no recognised language + // project — treat as a pure container assembly, not a compiled package. + hasLanguageProject := f.HasGoMod || f.HasCargoToml || f.HasPackageJSON || + f.HasPyProject || f.HasRequirements || f.HasSetupPy + if (f.HasDockerfile || f.HasContainerfile) && !hasLanguageProject { + return "container-assembly" + } + + switch { + case f.HasGoMod && f.HasMakefile: + if len(f.GoMainCandidates) > 1 { + return "go-make-multi-bin" + } + return "go-make" + + case f.HasGoMod && len(f.GoMainCandidates) > 1: + return "go-multi-bin" + + case f.HasGoMod: + return "go-simple" + + case f.HasCargoToml && cargoWorkspace(f.RepoDir): + return "rust-workspace" + + case f.HasCargoToml: + return "rust-simple" + + case f.HasPackageJSON: + switch f.NodePackageManager { + case "pnpm": + return "node-pnpm-app" + case "yarn": + return "node-yarn-app" + default: + return "node-npm-app" + } + + case f.HasPyProject || f.HasSetupPy: + return "python-wheel" + + case f.HasRequirements: + return "python-requirements" + + case f.HasMakefile: + return "generic-make" + + default: + _ = a + return "generic-placeholder" + } +} + +func trimGoModuleMajorSuffix(s string) string { + s = strings.TrimSpace(strings.Trim(s, "/")) + if s == "" { + return "" + } + parts := strings.Split(s, "/") + if len(parts) == 0 { + return s + } + last := strings.ToLower(parts[len(parts)-1]) + if looksSemanticMajorOnly(last) && len(parts) > 1 { + return strings.Join(parts[:len(parts)-1], "/") + } + return s +} + +func canonicalRepoName(name string) string { + name = sanitizeName(name) + if name == "" { + return "" + } + if looksSemanticMajorOnly(name) { + return "" + } + return name +} + +func isLikelyBuildHelperBinary(name string) bool { + name = strings.TrimSpace(strings.ToLower(name)) + if name == "" { + return false + } + if strings.HasPrefix(name, "protoc-gen-") { + return true + } + if strings.HasPrefix(name, "gen-") { + return true + } + switch name { + case "go-buildtag", "gen-manpages": + return true + default: + return false + } +} diff --git a/internal/specgen/outputplan.go b/internal/specgen/outputplan.go new file mode 100755 index 000000000..ff2767ac3 --- /dev/null +++ b/internal/specgen/outputplan.go @@ -0,0 +1,229 @@ +package specgen + +import "strings" + +type OutputProfile string + +const ( + OutputProfileAuto OutputProfile = "auto" + OutputProfilePackage OutputProfile = "package" + OutputProfilePackageContainer OutputProfile = "package+container" + OutputProfileContainer OutputProfile = "container" + OutputProfileSysext OutputProfile = "sysext" + OutputProfileWindowsCross OutputProfile = "windowscross" +) + +type IntentMode string + +const ( + IntentAuto IntentMode = "auto" + IntentPackage IntentMode = "package" + IntentPackageContainer IntentMode = "package+container" + IntentContainerOnly IntentMode = "container-only" + IntentSysext IntentMode = "sysext" + IntentWindowsCross IntentMode = "windowscross" +) + +type TargetFamily string + +const ( + TargetFamilyAuto TargetFamily = "auto" + TargetFamilyRPM TargetFamily = "rpm" + TargetFamilyDEB TargetFamily = "deb" + TargetFamilyBoth TargetFamily = "both" + TargetFamilyWindows TargetFamily = "windows" +) + +type TestMode string + +const ( + TestAuto TestMode = "auto" + TestAlways TestMode = "always" + TestNever TestMode = "never" +) + +type TargetRoute struct { + Name string `json:"name"` + Subtarget string `json:"subtarget,omitempty"` + Reason string `json:"reason,omitempty"` + Confidence string `json:"confidence,omitempty"` // high|medium|low +} + +type PlannedArtifact struct { + Kind string + Path string + Subpath string + Name string + Target string // actual Dalec target scoping only + BuildTarget string // language-specific source build target, e.g. ./cmd/operator + Required bool + Confidence string + Reason string + Mode uint32 + User string + Group string +} + +type PlannedDependency struct { + Name string `json:"name"` + Scope string `json:"scope"` // build|runtime|test|recommends|sysext + Target string `json:"target,omitempty"` + Version string `json:"version,omitempty"` + Constraints map[string]string `json:"constraints,omitempty"` + PackageManagers []string `json:"package_managers,omitempty"` + Confidence string `json:"confidence,omitempty"` // high|medium|low + Reason string `json:"reason,omitempty"` +} + +type PlannedTest struct { + Name string `json:"name"` + Dir string `json:"dir,omitempty"` + Steps []string `json:"steps,omitempty"` + Files map[string]string `json:"files,omitempty"` + Target string `json:"target,omitempty"` + Confidence string `json:"confidence,omitempty"` // high|medium|low + Reason string `json:"reason,omitempty"` +} + +type UserIntentPlan struct { + RequestedIntent IntentMode `json:"requested_intent,omitempty"` + RequestedTargetFamily TargetFamily `json:"requested_target_family,omitempty"` + RequestedMainComponent string `json:"requested_main_component,omitempty"` + RequestedBinaryNames []string `json:"requested_binary_names,omitempty"` + RequestedPackageName string `json:"requested_package_name,omitempty"` + RequestedBinaryName string `json:"requested_binary_name,omitempty"` + RequestedBuildStyle string `json:"requested_build_style,omitempty"` + RequestedBuildTarget string `json:"requested_build_target,omitempty"` + RequestedEntrypoint string `json:"requested_entrypoint,omitempty"` + RequestedCmd string `json:"requested_cmd,omitempty"` + RequestedTestMode TestMode `json:"requested_test_mode,omitempty"` + ExplicitFields []string `json:"explicit_fields,omitempty"` +} + +type SpecPlan struct { + SchemaVersion int `json:"schema_version"` + OutputProfile OutputProfile `json:"output_profile,omitempty"` + Intent IntentMode `json:"intent"` + TargetFamily TargetFamily `json:"target_family"` + + Routes []TargetRoute `json:"routes,omitempty"` + + MainComponent string `json:"main_component,omitempty"` + PackageName string `json:"package_name,omitempty"` + PrimaryBinaryName string `json:"primary_binary_name,omitempty"` + PrimaryBuildTarget string `json:"primary_build_target,omitempty"` + Description string `json:"description,omitempty"` + License string `json:"license,omitempty"` + Website string `json:"website,omitempty"` + + BuildStyle string `json:"build_style,omitempty"` + Entrypoint string `json:"entrypoint,omitempty"` + Cmd string `json:"cmd,omitempty"` + + // NetworkMode controls the build network policy. + // "none" for reproducible Go/Rust builds with vendored deps. + // "" or "sandbox" for builds that need outbound access. + NetworkMode string `json:"network_mode,omitempty"` + + // LDFlagsVarPath is the fully-qualified Go variable path for -ldflags version + // injection, e.g. "github.com/foo/bar/cmd.Version" or "main.version". + // Only meaningful for Go builds. + LDFlagsVarPath string `json:"ldflags_var_path,omitempty"` + + // CGOEnabled controls whether CGO_ENABLED=1 is set for Go builds. + // nil means auto (defaults to disabled for most baseline Go builds). + CGOEnabled *bool `json:"cgo_enabled,omitempty"` + + Args map[string]string `json:"args,omitempty"` + + UseTargets bool `json:"use_targets,omitempty"` + GenerateTests bool `json:"generate_tests,omitempty"` + + Artifacts []PlannedArtifact `json:"artifacts,omitempty"` + Dependencies []PlannedDependency `json:"dependencies,omitempty"` + Tests []PlannedTest `json:"tests,omitempty"` + + Decisions []DecisionRecord `json:"decisions,omitempty"` + Alternatives *Alternatives `json:"alternatives,omitempty"` + UserIntent *UserIntentPlan `json:"user_intent,omitempty"` + + Warnings []string `json:"warnings,omitempty"` + Unresolved []UnresolvedItem `json:"unresolved,omitempty"` +} + +func ParseOutputProfile(s string) OutputProfile { + switch strings.TrimSpace(strings.ToLower(s)) { + case string(OutputProfilePackage): + return OutputProfilePackage + case string(OutputProfilePackageContainer): + return OutputProfilePackageContainer + case string(OutputProfileContainer): + return OutputProfileContainer + case string(OutputProfileSysext): + return OutputProfileSysext + case string(OutputProfileWindowsCross): + return OutputProfileWindowsCross + default: + return OutputProfileAuto + } +} + +func IntentFromOutputProfile(p OutputProfile) IntentMode { + switch p { + case OutputProfilePackage: + return IntentPackage + case OutputProfilePackageContainer: + return IntentPackageContainer + case OutputProfileContainer: + return IntentContainerOnly + case OutputProfileSysext: + return IntentSysext + case OutputProfileWindowsCross: + return IntentWindowsCross + default: + return IntentAuto + } +} + +func ParseIntentMode(s string) IntentMode { + switch strings.TrimSpace(strings.ToLower(s)) { + case string(IntentPackage): + return IntentPackage + case string(IntentPackageContainer): + return IntentPackageContainer + case string(IntentContainerOnly): + return IntentContainerOnly + case string(IntentSysext): + return IntentSysext + case string(IntentWindowsCross): + return IntentWindowsCross + default: + return IntentAuto + } +} + +func ParseTargetFamily(s string) TargetFamily { + switch strings.TrimSpace(strings.ToLower(s)) { + case string(TargetFamilyRPM): + return TargetFamilyRPM + case string(TargetFamilyDEB): + return TargetFamilyDEB + case string(TargetFamilyBoth): + return TargetFamilyBoth + case string(TargetFamilyWindows): + return TargetFamilyWindows + default: + return TargetFamilyAuto + } +} + +func ParseTestMode(s string) TestMode { + switch strings.TrimSpace(strings.ToLower(s)) { + case string(TestAlways): + return TestAlways + case string(TestNever): + return TestNever + default: + return TestAuto + } +} diff --git a/internal/specgen/plan_deterministic.go b/internal/specgen/plan_deterministic.go new file mode 100755 index 000000000..ae4a13480 --- /dev/null +++ b/internal/specgen/plan_deterministic.go @@ -0,0 +1,1552 @@ +package specgen + +import ( + "path/filepath" + "sort" + "strings" +) + +func nativeBinaryBuildRelPath(name string) string { + name = sanitizeName(name) + if name == "" || name == "unknown" { + name = "app" + } + return "bin/" + name +} + +func nativeBinaryArtifactPath(name string) string { + return "src/" + nativeBinaryBuildRelPath(name) +} + +func deterministicPlan(opts Options, facts *RepoFacts, analysis *Analysis) *SpecPlan { + plan := &SpecPlan{ + SchemaVersion: 1, + Intent: opts.Intent, + TargetFamily: opts.TargetFamily, + UseTargets: opts.EmitTargets, + GenerateTests: false, + Args: defaultPlanArgs(opts, facts, analysis), + UserIntent: buildUserIntentPlan(opts), + } + + if plan.Intent == "" || plan.Intent == IntentAuto { + plan.Intent = chooseIntent(facts, analysis) + } + if plan.TargetFamily == "" || plan.TargetFamily == TargetFamilyAuto { + plan.TargetFamily = chooseTargetFamily(plan.Intent) + } + + if analysis != nil { + plan.Decisions = append(plan.Decisions, analysis.Decisions...) + plan.Alternatives = analysis.Alternatives + } + + plan.MainComponent = chooseDefaultComponent(facts, analysis, requestedMainComponent(opts)) + plan.PackageName = chooseDeterministicPackageName(facts, analysis, plan.MainComponent, strings.TrimSpace(opts.PackageName)) + plan.PrimaryBinaryName = choosePrimaryBinaryName(opts, plan.MainComponent, plan.PackageName) + plan.PrimaryBuildTarget = choosePrimaryBuildTarget(opts, facts, plan.MainComponent) + plan.Description = firstNonEmpty(metadataDescription(facts, analysis), "TODO: describe this package") + plan.License = firstNonEmpty(metadataLicense(facts, analysis), "TODO") + plan.Website = metadataWebsite(facts, analysis) + plan.BuildStyle = chooseBuildStyle(facts, analysis, strings.TrimSpace(opts.BuildStyle)) + if plan.Intent == IntentWindowsCross && looksLikeGoMultiPlatformMatrix(analysis) { + plan.Intent = IntentPackage + plan.TargetFamily = chooseTargetFamily(plan.Intent) + } + plan.NetworkMode = chooseNetworkMode(facts, plan.BuildStyle) + + // Apply explicit user overrides before runtime/routes/artifacts/tests are + // derived so emitted image/tests follow the requested primary binary. + applyExplicitPlanOverrides(opts, facts, analysis, plan) + + plan.Entrypoint, plan.Cmd = chooseRuntimeDefaults(facts, analysis, plan.MainComponent) + applyExplicitRuntimeOverrides(opts, plan) + plan.Routes = defaultRoutesForPlan(plan, facts, analysis) + plan.Artifacts = deterministicArtifacts(facts, analysis, plan) + + // If the user explicitly requested binaries, filter down to that set. + if len(opts.BinaryNames) > 0 { + plan.Artifacts = filterArtifactsForRequestedBinaries(plan.Artifacts, opts.BinaryNames) + if len(plan.Artifacts) == 0 && facts != nil && facts.PrimaryType == "go" { + plan.Artifacts = plannedGoBinaryArtifacts(opts.BinaryNames) + } + } + + // Merge user override deps with scored deps. + userDeps := append([]PlannedDependency(nil), plan.Dependencies...) + plan.Dependencies = deterministicDependencies(facts, analysis, plan) + plan.Dependencies = append(plan.Dependencies, userDeps...) + plan.Dependencies = dedupePlannedDeps(plan.Dependencies) + + plan.GenerateTests = defaultGenerateTests(opts.TestMode, facts, analysis, plan) + if plan.GenerateTests { + plan.Tests = deterministicTests(facts, analysis, plan) + } + + plan.Decisions = append(plan.Decisions, + DecisionRecord{ + Field: "intent", + Chosen: string(plan.Intent), + Confidence: confidenceForIntent(plan.Intent, facts, analysis), + Reason: "deterministic baseline selected package intent from repo signals", + }, + DecisionRecord{ + Field: "target_family", + Chosen: string(plan.TargetFamily), + Confidence: confidenceForTargetFamily(plan.TargetFamily), + Reason: "deterministic baseline selected target family from package intent", + }, + DecisionRecord{ + Field: "main_component", + Chosen: plan.MainComponent, + Confidence: planDecisionConfidence(plan.UserIntent, "main_component", confidenceFromAlternatives(plan.Alternatives, "components")), + Reason: planDecisionReason(plan.UserIntent, "main_component", "deterministic baseline selected the top-ranked component candidate"), + Evidence: bestAlternativeEvidenceForKind(plan.Alternatives, "components"), + }, + DecisionRecord{ + Field: "build_style", + Chosen: plan.BuildStyle, + Confidence: planDecisionConfidence(plan.UserIntent, "build_style", confidenceFromAlternatives(plan.Alternatives, "build_styles")), + Reason: planDecisionReason(plan.UserIntent, "build_style", "deterministic baseline selected the top-ranked build style"), + Evidence: bestAlternativeEvidenceForKind(plan.Alternatives, "build_styles"), + }, + DecisionRecord{ + Field: "package_name", + Chosen: plan.PackageName, + Confidence: planDecisionConfidence(plan.UserIntent, "package_name", confidenceFromAlternatives(plan.Alternatives, "package_names")), + Reason: planDecisionReason(plan.UserIntent, "package_name", "deterministic baseline selected the best package name candidate"), + Evidence: bestAlternativeEvidenceForKind(plan.Alternatives, "package_names"), + }, + DecisionRecord{ + Field: "entrypoint", + Chosen: plan.Entrypoint, + Confidence: planDecisionConfidence(plan.UserIntent, "entrypoint", confidenceFromAlternatives(plan.Alternatives, "entrypoints")), + Reason: planDecisionReason(plan.UserIntent, "entrypoint", "deterministic baseline selected runtime entrypoint defaults"), + Evidence: bestAlternativeEvidenceForKind(plan.Alternatives, "entrypoints"), + }, + ) + + if len(plan.Routes) == 0 { + plan.Unresolved = append(plan.Unresolved, UnresolvedItem{ + Code: "plan.routes.empty", + Message: "no target routes were selected", + Severity: "high", + }) + } + + if plan.Intent == IntentContainerOnly && plan.BuildStyle != "container-assembly" { + plan.Warnings = append(plan.Warnings, "container-only intent selected without a dedicated container assembly build style") + } + + if !planUserSpecified(plan.UserIntent, "main_component") && plan.Alternatives != nil && len(plan.Alternatives.Components) > 1 { + if absInt(plan.Alternatives.Components[0].Score-plan.Alternatives.Components[1].Score) <= 10 { + plan.Unresolved = append(plan.Unresolved, UnresolvedItem{ + Code: "component.selection_close", + Message: "top component candidates are close in score", + Severity: "medium", + Suggestions: []string{ + "inspect CLI usage, service units, and install targets", + }, + }) + } + } + + if !planUserSpecified(plan.UserIntent, "build_style") && plan.Alternatives != nil && len(plan.Alternatives.BuildStyles) > 1 { + if absInt(plan.Alternatives.BuildStyles[0].Score-plan.Alternatives.BuildStyles[1].Score) <= 10 { + plan.Unresolved = append(plan.Unresolved, UnresolvedItem{ + Code: "build_style.selection_close", + Message: "top build style candidates are close in score", + Severity: "medium", + Suggestions: []string{ + "inspect CI, Makefile, and release scripts to choose the intended baseline build path", + }, + }) + } + } + + plan.Unresolved = dedupeUnresolved(plan.Unresolved) + plan.Warnings = dedupeWarnings(plan.Warnings) + plan.Decisions = collapsePlanDecisionRecords(plan.Decisions) + + return plan +} + +func buildUserIntentPlan(opts Options) *UserIntentPlan { + intent := &UserIntentPlan{} + var explicit []string + if opts.Intent != "" && opts.Intent != IntentAuto { + intent.RequestedIntent = opts.Intent + explicit = append(explicit, "intent") + } + if opts.TargetFamily != "" && opts.TargetFamily != TargetFamilyAuto { + intent.RequestedTargetFamily = opts.TargetFamily + explicit = append(explicit, "target_family") + } + intent.RequestedMainComponent = sanitizeName(strings.TrimSpace(opts.MainComponent)) + if intent.RequestedMainComponent != "" { + explicit = append(explicit, "main_component") + } + if len(opts.BinaryNames) > 0 { + intent.RequestedBinaryNames = append([]string(nil), opts.BinaryNames...) + explicit = append(explicit, "binary_names") + } + intent.RequestedPackageName = sanitizeName(strings.TrimSpace(opts.PackageName)) + if intent.RequestedPackageName != "" { + explicit = append(explicit, "package_name") + } + intent.RequestedBinaryName = sanitizeName(strings.TrimSpace(opts.BinaryName)) + if intent.RequestedBinaryName != "" { + explicit = append(explicit, "binary_name") + } + intent.RequestedBuildStyle = strings.TrimSpace(opts.BuildStyle) + if intent.RequestedBuildStyle != "" { + explicit = append(explicit, "build_style") + } + intent.RequestedBuildTarget = strings.TrimSpace(opts.BuildTarget) + if intent.RequestedBuildTarget != "" { + explicit = append(explicit, "build_target") + } + intent.RequestedEntrypoint = strings.TrimSpace(opts.Entrypoint) + if intent.RequestedEntrypoint != "" { + explicit = append(explicit, "entrypoint") + } + intent.RequestedCmd = strings.TrimSpace(opts.Command) + if intent.RequestedCmd != "" { + explicit = append(explicit, "cmd") + } + if opts.TestMode != "" && opts.TestMode != TestAuto { + intent.RequestedTestMode = opts.TestMode + explicit = append(explicit, "test_mode") + } + intent.ExplicitFields = dedupeStrings(explicit) + if len(intent.ExplicitFields) == 0 { + return nil + } + return intent +} + +func planUserSpecified(intent *UserIntentPlan, field string) bool { + if intent == nil { + return false + } + for _, item := range intent.ExplicitFields { + if item == strings.TrimSpace(field) { + return true + } + } + return false +} + +func planDecisionReason(intent *UserIntentPlan, field, fallback string) string { + if planUserSpecified(intent, field) { + return "user-specified build intent overrode heuristic selection" + } + return fallback +} + +func planDecisionConfidence(intent *UserIntentPlan, field, fallback string) string { + if planUserSpecified(intent, field) { + return "high" + } + return fallback +} + +func requestedMainComponent(opts Options) string { + if s := sanitizeName(strings.TrimSpace(opts.MainComponent)); s != "" && s != "unknown" { + return s + } + if len(opts.BinaryNames) > 0 { + if s := sanitizeName(opts.BinaryNames[0]); s != "" && s != "unknown" { + return s + } + } + if s := sanitizeName(strings.TrimSpace(opts.BinaryName)); s != "" && s != "unknown" { + return s + } + return "" +} + +func firstBinaryName(items []string) string { + if len(items) == 0 { + return "" + } + return strings.TrimSpace(items[0]) +} + +func choosePrimaryBinaryName(opts Options, mainComponent, packageName string) string { + for _, candidate := range []string{opts.BinaryName, firstBinaryName(opts.BinaryNames), mainComponent, packageName} { + if s := sanitizeName(candidate); s != "" && s != "unknown" { + return s + } + } + return "app" +} + +func choosePrimaryBuildTarget(opts Options, f *RepoFacts, mainComponent string) string { + if s := strings.TrimSpace(opts.BuildTarget); s != "" { + return s + } + if f == nil || f.PrimaryType != "go" { + return "" + } + return chooseGoBuildTarget(f, sanitizeName(mainComponent)) +} + +func collapsePlanDecisionRecords(in []DecisionRecord) []DecisionRecord { + last := map[string]int{} + for i, d := range in { + field := strings.TrimSpace(d.Field) + if field == "" { + continue + } + last[field] = i + } + var out []DecisionRecord + for i, d := range in { + field := strings.TrimSpace(d.Field) + if field == "" { + continue + } + if last[field] != i { + continue + } + out = append(out, d) + } + return dedupeDecisionRecords(out) +} + +func defaultPlanArgs(opts Options, f *RepoFacts, a *Analysis) map[string]string { + if !opts.EmitArgs { + return nil + } + + args := map[string]string{ + "REVISION": "1", + } + + version := "" + if a != nil { + version = strings.TrimSpace(a.Metadata.Version) + } + if version == "" && f != nil { + version = strings.TrimSpace(f.Version) + } + if version == "" || looksTodo(version) { + args["VERSION"] = "" + } else { + args["VERSION"] = version + } + + return args +} + +func applyExplicitPlanOverrides(opts Options, facts *RepoFacts, analysis *Analysis, plan *SpecPlan) { + if plan == nil { + return + } + + if s := requestedMainComponent(opts); s != "" { + plan.MainComponent = s + } + if s := sanitizeName(strings.TrimSpace(opts.PackageName)); s != "" && s != "unknown" { + plan.PackageName = s + } + plan.PrimaryBinaryName = choosePrimaryBinaryName(opts, plan.MainComponent, plan.PackageName) + plan.PrimaryBuildTarget = choosePrimaryBuildTarget(opts, facts, plan.MainComponent) + if s := strings.TrimSpace(opts.BuildStyle); s != "" { + plan.BuildStyle = s + } + if s := strings.TrimSpace(opts.Entrypoint); s != "" { + plan.Entrypoint = s + } + if strings.TrimSpace(opts.Command) != "" { + plan.Cmd = strings.TrimSpace(opts.Command) + } + if strings.TrimSpace(opts.VersionVarPath) != "" { + plan.LDFlagsVarPath = strings.TrimSpace(opts.VersionVarPath) + } + if opts.CGOEnabled != nil { + plan.CGOEnabled = opts.CGOEnabled + } + + for _, dep := range opts.ExtraBuildDeps { + if dep = strings.TrimSpace(dep); dep != "" { + plan.Dependencies = append(plan.Dependencies, PlannedDependency{ + Name: dep, + Scope: "build", + Confidence: "high", + Reason: "user-supplied extra build dependency", + }) + } + } + for _, dep := range opts.ExtraRuntimeDeps { + if dep = strings.TrimSpace(dep); dep != "" { + plan.Dependencies = append(plan.Dependencies, PlannedDependency{ + Name: dep, + Scope: "runtime", + Confidence: "high", + Reason: "user-supplied extra runtime dependency", + }) + } + } +} + +// chooseNetworkMode returns the recommended build network policy for a given +// build style. Go and Rust builds with vendored/locked dependencies should use +// "none" for reproducibility. Make-driven and node builds may need outbound +// access and get an empty string (which means the dalec default — sandbox). +func applyExplicitRuntimeOverrides(opts Options, plan *SpecPlan) { + if plan == nil { + return + } + if s := strings.TrimSpace(opts.Entrypoint); s != "" { + plan.Entrypoint = s + } + if strings.TrimSpace(opts.Command) != "" { + plan.Cmd = strings.TrimSpace(opts.Command) + } +} + +func looksLikeGoMultiPlatformMatrix(a *Analysis) bool { + if a == nil { + return false + } + low := []string{} + for _, h := range a.BuildHints { + low = append(low, strings.ToLower(strings.TrimSpace(h.Command))) + } + joined := strings.Join(low, " ") + if !strings.Contains(joined, "goos=windows") { + return false + } + otherOS := 0 + for _, osName := range []string{"goos=linux", "goos=darwin", "goos=freebsd", "goos=android", "goos=openbsd", "goos=netbsd"} { + if strings.Contains(joined, osName) { + otherOS++ + } + } + return otherOS > 0 +} + +func chooseNetworkMode(f *RepoFacts, buildStyle string) string { + switch { + case buildStyle == "go-simple", + buildStyle == "go-multi-bin": + return "none" + case buildStyle == "rust-simple", + buildStyle == "rust-workspace": + return "none" + default: + // go-make and go-make-multi-bin may invoke arbitrary make targets that + // pull tooling — leave network open and let the user tighten it later. + return "" + } +} + +func chooseIntent(f *RepoFacts, a *Analysis) IntentMode { + if f == nil { + return IntentPackage + } + + if looksWindowsCrossRepo(f, a) { + return IntentWindowsCross + } + if looksSysextRepo(f, a) { + return IntentSysext + } + if looksContainerAssemblyRepo(f, a) { + return IntentContainerOnly + } + if prefersPackageContainerIntent(f, a) { + return IntentPackageContainer + } + return IntentPackage +} + +func prefersPackageContainerIntent(f *RepoFacts, a *Analysis) bool { + if f == nil || a == nil { + return false + } + if len(a.Services) > 0 { + return true + } + if strings.EqualFold(strings.TrimSpace(a.Runtime.Kind), "daemon") { + return true + } + for _, c := range a.Components { + if strings.EqualFold(strings.TrimSpace(c.Role), "daemon") { + return true + } + } + for _, art := range a.Artifacts { + if strings.EqualFold(strings.TrimSpace(art.Kind), "systemd") { + return true + } + } + for _, pe := range a.InstallLayout.Systemd { + if strings.TrimSpace(pe.Path) != "" { + return true + } + } + return false +} + +func chooseTargetFamily(intent IntentMode) TargetFamily { + switch intent { + case IntentWindowsCross: + return TargetFamilyWindows + case IntentContainerOnly: + return TargetFamilyBoth + case IntentSysext: + return TargetFamilyBoth + default: + return TargetFamilyBoth + } +} + +func looksWindowsCrossRepo(f *RepoFacts, a *Analysis) bool { + if f == nil { + return false + } + + parts := []string{f.Name, f.Description, f.Website} + if a != nil { + parts = append(parts, a.PackageHints...) + for _, h := range a.BuildHints { + parts = append(parts, h.Command) + } + for _, h := range a.InstallHints2 { + parts = append(parts, h.Command) + } + } + low := strings.ToLower(strings.Join(parts, " ")) + + if strings.Contains(low, "windowscross") || strings.Contains(low, "windows cross") { + return true + } + + if f.HasGoMod { + if !containsAnyFold(low, []string{ + "goos=windows", + "set goos=windows", + "export goos=windows", + "targetos=windows", + }) { + return false + } + if containsAnyFold(low, []string{ + "goos=linux", + "goos=darwin", + "goos=freebsd", + "goos=android", + "goos=solaris", + "goos=netbsd", + "goos=openbsd", + }) { + return false + } + return true + } + + if containsAnyFold(low, []string{ + "cargo xwin", + "cargo-xwin", + "llvm-mingw", + "mingw-w64", + "windres", + "signtool", + }) && strings.Contains(low, "windows") { + return true + } + return false +} + +func looksContainerAssemblyRepo(f *RepoFacts, a *Analysis) bool { + if f == nil { + return false + } + + hasLanguageProject := f.HasGoMod || f.HasCargoToml || f.HasPackageJSON || f.HasPyProject || f.HasRequirements || f.HasSetupPy + if (f.HasDockerfile || f.HasContainerfile) && !hasLanguageProject { + return true + } + + if a != nil { + if a.SelectedStrategy == "container-assembly" { + return true + } + if a.SelectedStrategy == "generic-placeholder" && (f.HasDockerfile || f.HasContainerfile) && !hasLikelyRuntime(f, a) { + return true + } + } + + return false +} + +func looksSysextRepo(f *RepoFacts, a *Analysis) bool { + if f == nil && a == nil { + return false + } + + var haystack []string + if f != nil { + haystack = append(haystack, f.Name, f.Description) + } + if a != nil { + haystack = append(haystack, a.InstallHints...) + a.ConfigPaths = dedupeStrings(a.ConfigPaths) + haystack = append(haystack, a.ConfigPaths...) + for _, h := range a.BuildHints { + haystack = append(haystack, h.Command) + } + for _, h := range a.InstallHints2 { + haystack = append(haystack, h.Command) + } + } + low := strings.ToLower(strings.Join(haystack, " ")) + + return strings.Contains(low, "sysext") || + strings.Contains(low, "extension-release") || + strings.Contains(low, "system extension") +} + +func hasLikelyRuntime(f *RepoFacts, a *Analysis) bool { + if a != nil && len(a.Services) > 0 { + return true + } + if f == nil { + return false + } + switch { + case len(f.GoMainCandidates) > 0: + return true + case strings.TrimSpace(f.CargoBinName) != "": + return true + case strings.TrimSpace(f.NodeBinName) != "": + return true + case strings.TrimSpace(f.NodeMain) != "": + return true + case strings.TrimSpace(f.PythonConsoleScript) != "": + return true + case strings.TrimSpace(f.PythonModuleName) != "" && (f.HasPyProject || f.HasSetupPy): + return true + default: + return false + } +} + +func chooseDefaultComponent(f *RepoFacts, a *Analysis, explicit string) string { + if s := sanitizeName(explicit); s != "" && s != "unknown" { + return s + } + + if s := preferredGoPrimaryComponent(f, a); s != "" { + return s + } + + if a != nil && a.Alternatives != nil && len(a.Alternatives.Components) > 0 { + if s := sanitizeName(a.Alternatives.Components[0].Value); s != "" && s != "unknown" { + return s + } + } + + if f != nil { + if len(f.GoMainCandidates) == 1 { + if rel := strings.TrimSpace(f.GoMainCandidates[0]); rel == "." || rel == "" { + if s := sanitizeName(f.Name); s != "" && s != "unknown" { + return s + } + } else { + if s := sanitizeName(filepath.Base(rel)); s != "" && s != "." && s != "unknown" { + return s + } + } + } + + for _, s := range []string{f.CargoBinName, f.NodeBinName, f.PythonConsoleScript} { + if s = sanitizeName(s); s != "" && s != "unknown" { + return s + } + } + + if s := sanitizeName(f.Name); s != "" && s != "unknown" { + return s + } + } + + if a != nil { + for _, c := range a.CandidateComponents { + if c = sanitizeName(c); c != "" && c != "unknown" { + return c + } + } + } + + return "app" +} + +func preferredGoPrimaryComponent(f *RepoFacts, a *Analysis) string { + if f == nil || f.PrimaryType != "go" || len(f.GoMainCandidates) == 0 { + return "" + } + + byName := map[string]string{} + for _, rel := range f.GoMainCandidates { + rel = filepath.ToSlash(strings.TrimSpace(rel)) + + name := "" + if rel == "." || rel == "" { + name = sanitizeName(f.Name) + } else { + name = sanitizeName(filepath.Base(rel)) + } + if name != "" && name != "unknown" { + byName[name] = rel + } + } + + if repo := sanitizeName(f.Name); repo != "" { + if _, ok := byName[repo]; ok { + return repo + } + } + + if a != nil { + if s := sanitizeName(a.Runtime.Entrypoint); s != "" && s != "unknown" { + if _, ok := byName[s]; ok && !isAuxiliaryGoBinary(s) { + return s + } + } + } + + for _, s := range []string{"operator", "controller", "manager", "server", "daemon", "agent", "apiserver", "api"} { + if _, ok := byName[s]; ok { + return s + } + } + + if rel := strings.TrimSpace(f.GoMainRel); rel != "" && rel != "." { + if s := sanitizeName(filepath.Base(rel)); s != "" && s != "unknown" && !isAuxiliaryGoBinary(s) { + return s + } + } + + for _, rel := range f.GoMainCandidates { + name := sanitizeName(filepath.Base(rel)) + if name != "" && name != "unknown" && !isAuxiliaryGoBinary(name) { + return name + } + } + + return "" +} + +func isAuxiliaryGoBinary(name string) bool { + switch sanitizeName(name) { + case "adapter", "webhooks", "admission-webhooks", "metrics-server", "metrics-apiserver", "proxy", "sidecar", "e2e", "test", "tests", "example", "examples": + return true + default: + return false + } +} + +func chooseDeterministicPackageName(f *RepoFacts, a *Analysis, main string, explicit string) string { + if s := sanitizeName(explicit); s != "" && s != "unknown" { + return s + } + if a != nil && a.Alternatives != nil && len(a.Alternatives.PackageNames) > 0 { + if s := sanitizeName(a.Alternatives.PackageNames[0].Value); s != "" && s != "unknown" { + return s + } + } + if f != nil { + if s := sanitizeName(f.Name); s != "" && s != "unknown" && !looksSemanticMajorOnly(s) { + return s + } + } + if s := sanitizeName(main); s != "" && s != "unknown" { + return s + } + if a != nil && a.Metadata.Name != "" { + if s := sanitizeName(a.Metadata.Name); s != "" && s != "unknown" { + return s + } + } + return "unknown" +} + +func metadataDescription(f *RepoFacts, a *Analysis) string { + if a != nil && strings.TrimSpace(a.Metadata.Description) != "" { + return a.Metadata.Description + } + if f != nil { + return f.Description + } + return "" +} + +func metadataLicense(f *RepoFacts, a *Analysis) string { + if a != nil && strings.TrimSpace(a.Metadata.License) != "" { + return a.Metadata.License + } + if f != nil { + return f.License + } + return "" +} + +func metadataWebsite(f *RepoFacts, a *Analysis) string { + website := "" + if a != nil && strings.TrimSpace(a.Metadata.Website) != "" { + website = a.Metadata.Website + } else if f != nil { + website = f.Website + } + if f != nil && looksInstallScriptURL(website) && strings.TrimSpace(f.GitRemoteURL) != "" { + return normalizeRepoURL(f.GitRemoteURL) + } + return website +} + +// chooseBuildStyle delegates to selectStrategy (helpers.go) as the single +// source of truth rather than duplicating the switch logic here. +func chooseBuildStyle(f *RepoFacts, a *Analysis, explicit string) string { + if s := strings.TrimSpace(explicit); s != "" { + return s + } + if looksContainerAssemblyRepo(f, a) { + return "container-assembly" + } + if s := preferredMixedBuildStyle(f, a); s != "" { + return s + } + if shouldPreferSelectedStrategy(f, a) { + return strings.TrimSpace(a.SelectedStrategy) + } + if f != nil { + if f.HasGoMod && !hasStrongRuntimeEvidence(a) { + return "generic-placeholder" + } + if f.HasCargoToml && strings.TrimSpace(f.CargoBinName) == "" && strings.TrimSpace(f.CargoPackageName) == "" && !hasStrongRuntimeEvidence(a) { + return "generic-placeholder" + } + if (f.PrimaryType == "go" || f.PrimaryType == "rust") && !hasLikelyRuntime(f, a) { + return "generic-placeholder" + } + } + // Prefer analysis alternatives — they carry scored evidence from the full + // repo scan and are more reliable than a simple manifest check. + if a != nil && a.Alternatives != nil && len(a.Alternatives.BuildStyles) > 0 { + if s := strings.TrimSpace(a.Alternatives.BuildStyles[0].Value); s != "" { + return s + } + } + if a != nil && strings.TrimSpace(a.SelectedStrategy) != "" { + return a.SelectedStrategy + } + if f == nil { + return "generic-placeholder" + } + return selectStrategy(f, a) +} + +func preferredMixedBuildStyle(f *RepoFacts, a *Analysis) string { + if f == nil { + return "" + } + if prefersRustForNodeWrapperRepo(f) { + if cargoWorkspace(f.RepoDir) || (a != nil && a.RepoShape.HasWorkspace) { + return "rust-workspace" + } + return "rust-simple" + } + return "" +} + +func hasStrongRuntimeEvidence(a *Analysis) bool { + if a == nil { + return false + } + if len(a.Services) > 0 { + return true + } + return normalizeConfidence(a.Runtime.Confidence) != "low" && strings.TrimSpace(a.Runtime.Entrypoint) != "" +} + +func shouldPreferSelectedStrategy(f *RepoFacts, a *Analysis) bool { + if f == nil || a == nil { + return false + } + selected := strings.TrimSpace(a.SelectedStrategy) + if selected == "" || selected == "generic-placeholder" || selected == "container-assembly" { + return false + } + languageCount := len(dedupeStrings(append([]string(nil), a.Languages...))) + if languageCount < 2 { + for _, present := range []bool{f.HasGoMod, f.HasCargoToml, f.HasPackageJSON, f.HasPyProject || f.HasRequirements || f.HasSetupPy} { + if present { + languageCount++ + } + } + } + if languageCount < 2 { + return false + } + return (strings.HasPrefix(selected, "rust") && f.HasCargoToml) || + (strings.HasPrefix(selected, "go") && f.HasGoMod) || + (strings.HasPrefix(selected, "python") && (f.HasPyProject || f.HasRequirements || f.HasSetupPy)) +} + +func chooseRuntimeDefaults(f *RepoFacts, a *Analysis, main string) (string, string) { + main = sanitizeName(main) + if main != "" && main != "unknown" { + switch { + case f != nil && (f.PrimaryType == "go" || f.PrimaryType == "rust") && hasLikelyRuntime(f, a): + return main, "--help" + case f != nil && f.PrimaryType == "unknown": + return main, "" + case f != nil && f.PrimaryType == "node" && hasLikelyRuntime(f, a): + if strings.TrimSpace(f.NodeMain) != "" && main == "node" { + return "node", f.NodeMain + } + return main, "" + case f != nil && f.PrimaryType == "python" && hasLikelyRuntime(f, a): + if strings.TrimSpace(f.PythonModuleName) != "" && main == "python3" { + return "python3", "-m " + f.PythonModuleName + } + return main, "" + } + } + + if hasLikelyRuntime(f, a) && a != nil && strings.TrimSpace(a.Runtime.Entrypoint) != "" { + return a.Runtime.Entrypoint, a.Runtime.Cmd + } + + if hasLikelyRuntime(f, a) && a != nil && a.Alternatives != nil && len(a.Alternatives.EntryPoints) > 0 { + entry := strings.TrimSpace(a.Alternatives.EntryPoints[0].Value) + if entry != "" { + if entry == "node" && f != nil && strings.TrimSpace(f.NodeMain) != "" { + return "node", f.NodeMain + } + if entry == "python3" && f != nil && strings.TrimSpace(f.PythonModuleName) != "" { + return "python3", "-m " + f.PythonModuleName + } + return entry, "" + } + } + + if f == nil { + return sanitizeName(main), "" + } + + switch f.PrimaryType { + case "go", "rust": + if hasLikelyRuntime(f, a) { + return sanitizeName(main), "--help" + } + case "node": + if hasLikelyRuntime(f, a) && f.NodeMain != "" { + return "node", f.NodeMain + } + if hasLikelyRuntime(f, a) && f.NodeBinName != "" { + return f.NodeBinName, "" + } + case "python": + if hasLikelyRuntime(f, a) && f.PythonConsoleScript != "" { + return f.PythonConsoleScript, "" + } + if hasLikelyRuntime(f, a) && f.PythonModuleName != "" { + return "python3", "-m " + f.PythonModuleName + } + } + + return "", "" +} + +func defaultRoutesForPlan(plan *SpecPlan, f *RepoFacts, a *Analysis) []TargetRoute { + switch plan.TargetFamily { + case TargetFamilyRPM: + return []TargetRoute{ + {Name: "azlinux3", Subtarget: "rpm", Confidence: "medium", Reason: "default RPM target family route"}, + } + case TargetFamilyDEB: + return []TargetRoute{ + {Name: "jammy", Subtarget: "deb", Confidence: "medium", Reason: "default DEB target family route"}, + {Name: "noble", Subtarget: "deb", Confidence: "low", Reason: "secondary DEB route for baseline portability"}, + } + case TargetFamilyWindows: + return []TargetRoute{ + {Name: "windowscross", Subtarget: "container", Confidence: "medium", Reason: "windows cross-compilation route"}, + } + default: + routes := []TargetRoute{ + {Name: "azlinux3", Subtarget: "rpm", Confidence: "medium", Reason: "default RPM baseline route"}, + {Name: "jammy", Subtarget: "deb", Confidence: "medium", Reason: "default DEB baseline route"}, + } + if plan.Intent == IntentPackageContainer || plan.Intent == IntentContainerOnly { + routes = append(routes, TargetRoute{ + Name: "azlinux3", + Subtarget: "container", + Confidence: "low", + Reason: "container-capable route included for runtime image refinement", + }) + } + return dedupeRoutes(routes) + } +} + +func deterministicArtifacts(f *RepoFacts, a *Analysis, plan *SpecPlan) []PlannedArtifact { + var out []PlannedArtifact + + add := func(kind, path, subpath, name, target, confidence, reason string, required bool) { + path = strings.TrimSpace(path) + if path == "" { + return + } + out = append(out, PlannedArtifact{ + Kind: kind, + Path: path, + Subpath: strings.TrimSpace(subpath), + Name: strings.TrimSpace(name), + Target: strings.TrimSpace(target), + Required: required, + Confidence: normalizeConfidence(confidence), + Reason: strings.TrimSpace(reason), + }) + } + + installedName := sanitizeName(plan.PrimaryBinaryName) + if installedName == "" || installedName == "unknown" { + installedName = sanitizeName(plan.MainComponent) + } + if installedName == "" || installedName == "unknown" { + installedName = sanitizeName(plan.PackageName) + } + if installedName == "" || installedName == "unknown" { + installedName = "app" + } + + buildTargetName := sanitizeName(plan.MainComponent) + if buildTargetName == "" || buildTargetName == "unknown" { + buildTargetName = installedName + } + buildTarget := strings.TrimSpace(plan.PrimaryBuildTarget) + if buildTarget == "" { + buildTarget = chooseGoBuildTarget(f, buildTargetName) + } + + if plan.Intent != IntentContainerOnly { + switch { + case strings.HasPrefix(plan.BuildStyle, "go") && hasLikelyRuntime(f, a): + path := nativeBinaryArtifactPath(installedName) + if plan.Intent == IntentWindowsCross || plan.TargetFamily == TargetFamilyWindows { + path += ".exe" + } + out = append(out, PlannedArtifact{ + Kind: "binary", + Path: path, + Name: installedName, + BuildTarget: buildTarget, + Required: true, + Confidence: normalizeConfidence("high"), + Reason: "primary Go package binary", + }) + + case strings.HasPrefix(plan.BuildStyle, "rust") && hasLikelyRuntime(f, a): + add("binary", "src/target/release/"+installedName, "", installedName, "", "high", "primary Cargo package binary", true) + } + } + + if a != nil && shouldEmitManpagesInBaseline(a, plan) { + for _, pe := range a.InstallLayout.Manpages { + add("manpage", pe.Path, "", filepath.Base(pe.Path), "", pe.Confidence, pe.Reason, false) + } + } + + if a != nil { + for _, pe := range a.InstallLayout.Docs { + if isDirectoryLikeArtifactPath(pe.Path) && !shouldEmitDocDirsInBaseline(a, plan) { + continue + } + add("doc", pe.Path, "", filepath.Base(pe.Path), "", pe.Confidence, pe.Reason, false) + } + } + + if a != nil && len(a.InstallLayout.Systemd) > 0 { + for _, pe := range a.InstallLayout.Systemd { + add("systemd", pe.Path, "", filepath.Base(pe.Path), "", pe.Confidence, pe.Reason, false) + } + } + if a != nil { + for _, pe := range a.InstallLayout.ConfigFiles { + add("config", pe.Path, "", filepath.Base(pe.Path), "", pe.Confidence, pe.Reason, false) + } + for _, pe := range a.InstallLayout.DataDirs { + add("data_dir", pe.Path, "", filepath.Base(pe.Path), "", pe.Confidence, pe.Reason, false) + } + for _, pe := range a.InstallLayout.Libexec { + add("libexec", pe.Path, "", filepath.Base(pe.Path), "", pe.Confidence, pe.Reason, false) + } + } + if f != nil && strings.TrimSpace(f.LicensePath) != "" && plan.Intent != IntentContainerOnly { + add("license", filepath.ToSlash(strings.TrimSpace(f.LicensePath)), "", filepath.Base(f.LicensePath), "", "medium", "license file discovered in repository", false) + } + + return dedupePlannedArtifacts(out) +} + +func filterArtifactsForRequestedBinaries(in []PlannedArtifact, names []string) []PlannedArtifact { + if len(names) == 0 { + return in + } + wanted := make(map[string]struct{}, len(names)) + for _, n := range names { + if n = sanitizeName(n); n != "" && n != "unknown" { + wanted[n] = struct{}{} + } + } + + var out []PlannedArtifact + for _, art := range in { + if strings.TrimSpace(art.Kind) != "binary" { + out = append(out, art) + continue + } + if _, ok := wanted[artifactInstalledName(art, "")]; ok { + out = append(out, art) + } + } + return dedupePlannedArtifacts(out) +} + +func plannedGoBinaryArtifacts(names []string) []PlannedArtifact { + var out []PlannedArtifact + for _, n := range names { + n = sanitizeName(n) + if n == "" || n == "unknown" { + continue + } + out = append(out, PlannedArtifact{ + Kind: "binary", + Name: n, + Path: nativeBinaryArtifactPath(n), + Confidence: "high", + Reason: "explicit user-requested Go binary", + }) + } + return dedupePlannedArtifacts(out) +} + +func deterministicDependencies(f *RepoFacts, a *Analysis, plan *SpecPlan) []PlannedDependency { + var out []PlannedDependency + + add := func(scope, name, confidence, reason string) { + name = strings.TrimSpace(name) + scope = strings.TrimSpace(scope) + if name == "" || scope == "" { + return + } + out = append(out, PlannedDependency{ + Name: name, + Scope: scope, + Confidence: normalizeConfidence(confidence), + Reason: strings.TrimSpace(reason), + }) + } + + switch { + case strings.HasPrefix(plan.BuildStyle, "go"): + if f != nil && !f.GoNeedsManagedToolchain { + add("build", "golang", "high", "Go repository detected") + } + if f != nil && f.HasMakefile { + add("build", "make", "high", "top-level Makefile detected") + } + if f != nil && f.GoNeedsManagedToolchain { + add("build", "ca-certificates", "medium", "managed Go toolchain download") + add("build", "tar", "medium", "managed Go toolchain extraction") + } + case strings.HasPrefix(plan.BuildStyle, "rust"): + add("build", "rust", "high", "Cargo repository detected") + add("build", "cargo", "high", "Cargo repository detected") + case strings.HasPrefix(plan.BuildStyle, "node"): + add("build", "nodejs", "high", "Node package manifest detected") + add("runtime", "nodejs", "medium", "Node application runtime") + switch { + case strings.HasPrefix(plan.BuildStyle, "node-pnpm"): + add("build", "pnpm", "medium", "pnpm-based Node baseline") + case strings.HasPrefix(plan.BuildStyle, "node-yarn"): + add("build", "yarn", "medium", "yarn-based Node baseline") + default: + add("build", "npm", "medium", "npm-based Node baseline") + } + case strings.HasPrefix(plan.BuildStyle, "python"): + add("build", "python3", "high", "Python project detected") + add("build", "python3-pip", "medium", "Python packaging baseline") + add("runtime", "python3", "medium", "Python application runtime") + case strings.HasPrefix(plan.BuildStyle, "generic-make"): + add("build", "make", "high", "top-level Makefile detected") + case plan.Intent == IntentContainerOnly: + // No default build deps; container-only may be pure assembly. + } + if hasPlannedArtifactKind(plan.Artifacts, "systemd") { + add("runtime", "systemd", "low", "systemd service unit detected") + } + + if a != nil { + for _, hint := range append(append([]CommandHint(nil), a.BuildHints...), a.InstallHints2...) { + h := strings.ToLower(hint.Command) + switch { + case strings.Contains(h, "pkg-config"): + add("build", "pkg-config", "medium", "pkg-config usage detected") + case strings.Contains(h, "gcc") || strings.Contains(h, "clang") || strings.Contains(h, " cc "): + add("build", "gcc", "low", "native compiler usage detected") + case strings.Contains(h, "go-md2man") || strings.Contains(h, "md2man"): + add("build", "go-md2man", "low", "manpage generation hint detected") + case strings.Contains(h, "tar "): + add("build", "tar", "low", "archive tooling detected") + case strings.Contains(h, "gzip") || strings.Contains(h, "gunzip"): + add("build", "gzip", "low", "compression tooling detected") + case strings.Contains(h, "rsync "): + add("build", "rsync", "low", "rsync usage detected") + } + } + + for _, h := range a.TestHints { + low := strings.ToLower(h.Command) + switch { + case strings.Contains(low, "pytest"): + add("test", "python3-pytest", "low", "pytest test hint detected") + } + } + + for range a.ManpagePaths { + if hasManpageBuildHints(a) { + add("build", "go-md2man", "low", "go-md2man usage detected in build hints") + } + } + } + + return dedupePlannedDeps(out) +} + +func defaultGenerateTests(mode TestMode, f *RepoFacts, a *Analysis, plan *SpecPlan) bool { + switch mode { + case TestAlways: + return true + case TestNever: + return false + } + + if plan.Intent == IntentContainerOnly { + return false + } + if len(plan.Artifacts) > 0 { + return true + } + if plan.Entrypoint != "" { + return true + } + if hasLikelyRuntime(f, a) { + return true + } + if a != nil && (len(a.Services) > 0 || len(a.TestHints) > 0) { + return true + } + + return false +} + +func deterministicTests(f *RepoFacts, a *Analysis, plan *SpecPlan) []PlannedTest { + name := sanitizeName(plan.PrimaryBinaryName) + if name == "" || name == "unknown" { + name = sanitizeName(plan.MainComponent) + } + if name == "" || name == "unknown" { + name = "smoke" + } + + var tests []PlannedTest + + files := map[string]string{} + for _, art := range plan.Artifacts { + switch art.Kind { + case "binary": + files["/usr/bin/"+artifactInstalledName(art, name)] = "exists" + case "manpage": + files["/usr/share/man/"+manpageInstalledSubpath(art.Path)] = "exists" + case "config": + files[defaultConfigInstallPath(art.Path)] = "exists" + case "systemd": + files["/usr/lib/systemd/system/"+filepath.Base(art.Path)] = "exists" + } + } + + if len(files) > 0 { + tests = append(tests, PlannedTest{ + Name: name + "-files", + Files: files, + Confidence: "medium", + Reason: "file-based validation generated from planned artifacts", + }) + } + + smokeBin := plannedSmokeBinary(plan) + if smokeBin == "" { + smokeBin = sanitizeName(plan.Entrypoint) + } + + if smokeBin != "" { + installedBin := "/usr/bin/" + smokeBin + cmd := installedBin + if strings.TrimSpace(plan.Cmd) != "" { + cmd += " " + strings.TrimSpace(plan.Cmd) + } + + conf := "low" + reason := "runtime smoke test generated from entrypoint" + if strings.HasPrefix(plan.BuildStyle, "go") || strings.HasPrefix(plan.BuildStyle, "rust") { + conf = "medium" + reason = "runtime smoke test generated from planned binary artifact" + } + + tests = append(tests, PlannedTest{ + Name: name + "-smoke", + Steps: []string{cmd}, + Confidence: conf, + Reason: reason, + }) + } + + return dedupePlannedTests(tests) +} + +func plannedSmokeBinary(plan *SpecPlan) string { + if plan == nil { + return "" + } + for _, art := range plan.Artifacts { + if strings.TrimSpace(art.Kind) == "binary" { + return artifactInstalledName(art, sanitizeName(plan.PrimaryBinaryName)) + } + } + return "" +} + +func artifactInstalledName(art PlannedArtifact, fallback string) string { + if s := sanitizeName(art.Name); s != "" && s != "unknown" { + return s + } + base := filepath.Base(strings.TrimSpace(art.Path)) + base = strings.TrimSuffix(base, ".exe") + if s := sanitizeName(base); s != "" && s != "unknown" { + return s + } + return fallback +} + +func manpageInstalledSubpath(src string) string { + base := filepath.Base(strings.TrimSpace(src)) + if base == "" { + return "man1/app.1" + } + section := "1" + parts := strings.Split(base, ".") + if len(parts) >= 2 { + last := parts[len(parts)-1] + if len(last) == 1 && last[0] >= '1' && last[0] <= '9' { + section = last + } + } + return "man" + section + "/" + base +} + +func defaultConfigInstallPath(src string) string { + src = filepath.ToSlash(strings.TrimSpace(src)) + base := filepath.Base(src) + if base == "" { + base = "app.conf" + } + if strings.HasPrefix(src, "etc/") { + return "/" + strings.TrimPrefix(src, "./") + } + if strings.HasPrefix(base, "etc_") { + named := strings.TrimPrefix(base, "etc_") + if named == "" { + named = base + } + return "/etc/" + strings.ReplaceAll(named, "_", "/") + } + return "/etc/" + base +} + +func dedupePlannedArtifacts(in []PlannedArtifact) []PlannedArtifact { + seen := map[string]struct{}{} + out := make([]PlannedArtifact, 0, len(in)) + for _, item := range in { + key := item.Kind + "|" + item.Path + "|" + item.Target + "|" + item.BuildTarget + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, item) + } + sort.Slice(out, func(i, j int) bool { + if out[i].Kind == out[j].Kind { + return out[i].Path < out[j].Path + } + return out[i].Kind < out[j].Kind + }) + return out +} + +func dedupePlannedDeps(in []PlannedDependency) []PlannedDependency { + seen := map[string]struct{}{} + out := make([]PlannedDependency, 0, len(in)) + for _, item := range in { + key := item.Scope + "|" + item.Name + "|" + item.Target + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, item) + } + sort.Slice(out, func(i, j int) bool { + if out[i].Scope == out[j].Scope { + return out[i].Name < out[j].Name + } + return out[i].Scope < out[j].Scope + }) + return out +} + +func dedupePlannedTests(in []PlannedTest) []PlannedTest { + seen := map[string]struct{}{} + out := make([]PlannedTest, 0, len(in)) + for _, item := range in { + key := strings.TrimSpace(item.Name) + "|" + strings.Join(item.Steps, ";") + if len(item.Files) > 0 { + keys := make([]string, 0, len(item.Files)) + for k := range item.Files { + keys = append(keys, k) + } + sort.Strings(keys) + key += "|" + strings.Join(keys, ";") + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, item) + } + sort.Slice(out, func(i, j int) bool { + return out[i].Name < out[j].Name + }) + return out +} + +func hasPlannedArtifactKind(in []PlannedArtifact, kind string) bool { + for _, a := range in { + if strings.TrimSpace(a.Kind) == kind { + return true + } + } + return false +} + +func dedupeRoutes(in []TargetRoute) []TargetRoute { + seen := map[string]struct{}{} + out := make([]TargetRoute, 0, len(in)) + for _, item := range in { + key := strings.TrimSpace(item.Name) + "|" + strings.TrimSpace(item.Subtarget) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, item) + } + sort.Slice(out, func(i, j int) bool { + if out[i].Name == out[j].Name { + return out[i].Subtarget < out[j].Subtarget + } + return out[i].Name < out[j].Name + }) + return out +} + +func confidenceForIntent(intent IntentMode, f *RepoFacts, a *Analysis) string { + switch intent { + case IntentWindowsCross, IntentSysext, IntentContainerOnly: + return "medium" + case IntentPackageContainer: + if hasLikelyRuntime(f, a) { + return "high" + } + return "medium" + default: + return "medium" + } +} + +func confidenceForTargetFamily(family TargetFamily) string { + switch family { + case TargetFamilyWindows: + return "high" + case TargetFamilyRPM, TargetFamilyDEB: + return "high" + default: + return "medium" + } +} + +func confidenceFromAlternatives(a *Alternatives, kind string) string { + if a == nil { + return "low" + } + + var choices []ScoredChoice + switch kind { + case "components": + choices = a.Components + case "build_styles": + choices = a.BuildStyles + case "entrypoints": + choices = a.EntryPoints + case "package_names": + choices = a.PackageNames + default: + return "low" + } + + if len(choices) == 0 { + return "low" + } + return normalizeConfidence(choices[0].Confidence) +} + +func bestAlternativeEvidenceForKind(a *Alternatives, kind string) []string { + if a == nil { + return nil + } + + switch kind { + case "components": + if len(a.Components) > 0 { + return append([]string(nil), a.Components[0].Evidence...) + } + case "build_styles": + if len(a.BuildStyles) > 0 { + return append([]string(nil), a.BuildStyles[0].Evidence...) + } + case "entrypoints": + if len(a.EntryPoints) > 0 { + return append([]string(nil), a.EntryPoints[0].Evidence...) + } + case "package_names": + if len(a.PackageNames) > 0 { + return append([]string(nil), a.PackageNames[0].Evidence...) + } + } + + return nil +} diff --git a/internal/specgen/specgen.go b/internal/specgen/specgen.go new file mode 100644 index 000000000..e6817f726 --- /dev/null +++ b/internal/specgen/specgen.go @@ -0,0 +1,217 @@ +package specgen + +import ( + "bytes" + "context" + "fmt" + "path/filepath" + "strings" + + "github.com/project-dalec/dalec" + "gopkg.in/yaml.v3" +) + +type Options struct { + RepoDir string + OutFile string + SourceMode string + ForcedType string + SyntaxImage string + RepoURL string + + Intent IntentMode + TargetFamily TargetFamily + MainComponent string + TestMode TestMode + EmitTargets bool + + // Baseline-focused options. + BundleOut string + PreferGitSource bool + EmitArgs bool + RichPlan bool + + // User-supplied build intent. + // These fields are applied before heuristic ranking so repo discovery answers + // "what is available" while user intent answers "what should be built". + BinaryNames []string + PackageName string + BinaryName string + BuildStyle string + BuildTarget string + Entrypoint string + Command string + + // ExtraBuildDeps is a list of extra build-time package dependencies to + // include in spec.dependencies.build, e.g. ["libssl-dev", "zlib1g-dev"]. + ExtraBuildDeps []string + + // ExtraRuntimeDeps is a list of extra runtime package dependencies to + // include in spec.dependencies.runtime. + ExtraRuntimeDeps []string + + // VersionVarPath is the fully-qualified Go ldflags version variable path, + // e.g. "main.version" or "github.com/foo/bar/cmd.Version". + // When set, the baseline emits -ldflags "-X =${VERSION}". + VersionVarPath string + + // CGOEnabled explicitly overrides the CGO_ENABLED build environment variable. + // nil means auto (defaults to off for most Go builds for reproducibility). + CGOEnabled *bool + + // UserHints is free-text guidance forwarded to the AI refinement stage. + // Ignored by the deterministic baseline generator. + UserHints string + + // Legacy AI fields kept temporarily as no-ops so callers do not break + // while we refactor the rest of the package file-by-file. + BaselineOnly bool + AIStage string + UseAI bool + AIOnUnknown bool + AIOnWeak bool + LLMCmd string + MaxFiles int + MaxBytes int +} + +type Result struct { + YAML []byte + DetectedType string + Warnings []string + Spec *dalec.Spec + Analysis *Analysis + Plan *SpecPlan + Unresolved []UnresolvedItem + Bundle *BaselineBundle +} + +// BaselineBundle is intentionally minimal in this file so specgen.go can move +// to a baseline-first flow immediately. We will enrich this further when we +// update types.go / analysis.go / outputplan.go. +type BaselineBundle struct { + SchemaVersion int `json:"schema_version"` + RepoDir string `json:"repo_dir"` + DetectedType string `json:"detected_type"` + Warnings []string `json:"warnings,omitempty"` + Analysis *Analysis `json:"analysis,omitempty"` + Plan *SpecPlan `json:"plan,omitempty"` + Unresolved []UnresolvedItem `json:"unresolved,omitempty"` + Spec *dalec.Spec `json:"spec,omitempty"` +} + +// Generate is kept for compatibility with existing callers. +// Internally it now runs the deterministic baseline-only pipeline. +func Generate(ctx context.Context, opts Options) (*Result, error) { + return GenerateBaseline(ctx, opts) +} + +func GenerateBaseline(ctx context.Context, opts Options) (*Result, error) { + opts = normalizeBaselineOptions(opts) + + repoDir, err := filepath.Abs(opts.RepoDir) + if err != nil { + return nil, err + } + opts.RepoDir = repoDir + + facts, warnings, err := DetectRepo(repoDir, opts.ForcedType) + if err != nil { + return nil, err + } + if strings.TrimSpace(opts.RepoURL) != "" && strings.TrimSpace(facts.GitRemoteURL) == "" { + facts.GitRemoteURL = strings.TrimSpace(opts.RepoURL) + } + if opts.PreferGitSource && strings.EqualFold(strings.TrimSpace(opts.SourceMode), "context") && + strings.TrimSpace(facts.GitRemoteURL) != "" && strings.TrimSpace(facts.GitCommit) != "" { + opts.SourceMode = "git" + } + + analysis, analysisWarnings := AnalyzeRepo(facts) + warnings = append(warnings, analysisWarnings...) + + plan := deterministicPlan(opts, facts, analysis) + if plan == nil { + return nil, fmt.Errorf("deterministic planning returned nil") + } + if err := validatePlan(plan); err != nil { + return nil, err + } + + spec, buildWarnings, err := BuildSpec(ctx, analysis, plan, opts.SourceMode) + if err != nil { + return nil, err + } + warnings = append(warnings, buildWarnings...) + + if err := validateFinalSpec(spec, plan); err != nil { + return nil, fmt.Errorf("generated spec failed validation: %w", err) + } + + specGaps := FindSpecGaps(analysis, spec) + + unresolved := append([]UnresolvedItem(nil), analysis.Unresolved...) + unresolved = append(unresolved, specGaps...) + unresolved = dedupeUnresolved(unresolved) + + warnings = dedupeWarnings(warnings) + + var buf bytes.Buffer + if strings.TrimSpace(opts.SyntaxImage) != "" { + fmt.Fprintf(&buf, "# syntax=%s\n", strings.TrimSpace(opts.SyntaxImage)) + } + + enc := yaml.NewEncoder(&buf) + enc.SetIndent(2) + if err := enc.Encode(spec); err != nil { + return nil, err + } + if err := enc.Close(); err != nil { + return nil, err + } + + bundle := &BaselineBundle{ + SchemaVersion: 1, + RepoDir: repoDir, + DetectedType: facts.PrimaryType, + Warnings: append([]string(nil), warnings...), + Analysis: analysis, + Plan: plan, + Unresolved: append([]UnresolvedItem(nil), unresolved...), + Spec: spec, + } + + return &Result{ + YAML: buf.Bytes(), + DetectedType: facts.PrimaryType, + Warnings: warnings, + Spec: spec, + Analysis: analysis, + Plan: plan, + Unresolved: unresolved, + Bundle: bundle, + }, nil +} + +func normalizeBaselineOptions(opts Options) Options { + if strings.TrimSpace(opts.RepoDir) == "" { + opts.RepoDir = "." + } + if strings.TrimSpace(opts.SyntaxImage) == "" { + opts.SyntaxImage = "ghcr.io/project-dalec/dalec/frontend:latest" + } + if strings.TrimSpace(opts.SourceMode) == "" { + opts.SourceMode = "context" + } + if opts.Intent == "" { + opts.Intent = IntentAuto + } + if opts.TargetFamily == "" { + opts.TargetFamily = TargetFamilyAuto + } + if opts.TestMode == "" { + opts.TestMode = TestAuto + } + + return opts +} diff --git a/internal/specgen/specgen_test.go b/internal/specgen/specgen_test.go new file mode 100644 index 000000000..cd6440be1 --- /dev/null +++ b/internal/specgen/specgen_test.go @@ -0,0 +1,497 @@ +package specgen + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestGenerate_GoRepo_EmitsGomodGeneratorWhenSafe(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module example.com/foo\ngo 1.25\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main\nfunc main(){}\n"), 0o644); err != nil { + t.Fatal(err) + } + + res, err := Generate(context.Background(), Options{RepoDir: dir, SyntaxImage: "ghcr.io/project-dalec/dalec/frontend:latest"}) + if err != nil { + t.Fatal(err) + } + s := string(res.YAML) + if !strings.Contains(s, "# syntax=ghcr.io/project-dalec/dalec/frontend:latest") { + t.Fatalf("missing syntax header:\n%s", s) + } + if !strings.Contains(s, "gomod") { + t.Fatalf("expected gomod generator for safe go.mod:\n%s", s) + } + if !strings.Contains(s, "golang") { + t.Fatalf("expected golang build dep:\n%s", s) + } + if !strings.Contains(s, "go build") { + t.Fatalf("expected go build step:\n%s", s) + } + if res.Plan == nil || res.Plan.Intent != IntentPackage { + t.Fatalf("expected package plan, got %+v", res.Plan) + } +} + +func TestGenerate_GoRepo_SkipsUnsafeGomodGenerator(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module example.com/foo\ngo 1.25.0\ntool (\n\tstringer\n)\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main\nfunc main(){}\n"), 0o644); err != nil { + t.Fatal(err) + } + + res, err := Generate(context.Background(), Options{RepoDir: dir, SyntaxImage: "ghcr.io/project-dalec/dalec/frontend:latest"}) + if err != nil { + t.Fatal(err) + } + s := string(res.YAML) + if strings.Contains(s, "gomod") { + t.Fatalf("did not expect gomod generator for unsafe go.mod:\n%s", s) + } + if !strings.Contains(s, "go build") { + t.Fatalf("expected direct go build step:\n%s", s) + } + if !strings.Contains(s, "golang") { + t.Fatalf("expected golang build dep:\n%s", s) + } +} + +func TestGenerate_NodeRepo_UsesNodeAdapter(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{ + "name": "hello-node", + "version": "1.2.3", + "license": "MIT", + "description": "Hello Node", + "main": "dist/index.js", + "scripts": {"build": "node build.js"} +}`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "build.js"), []byte("console.log('ok')\n"), 0o644); err != nil { + t.Fatal(err) + } + + res, err := Generate(context.Background(), Options{RepoDir: dir, SyntaxImage: "ghcr.io/project-dalec/dalec/frontend:latest"}) + if err != nil { + t.Fatal(err) + } + s := string(res.YAML) + if !strings.Contains(s, "nodejs") { + t.Fatalf("expected nodejs build dep:\n%s", s) + } + if !strings.Contains(s, "npm") { + t.Fatalf("expected npm-related build path:\n%s", s) + } + if !strings.Contains(s, "npm run build") { + t.Fatalf("expected node build step:\n%s", s) + } + if res.Plan == nil || !strings.HasPrefix(res.Plan.BuildStyle, "node") { + t.Fatalf("expected node build style, got %+v", res.Plan) + } +} + +func TestGenerate_GitSourcePreferredWhenMetadataAvailable(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module example.com/foo\ngo 1.25\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main\nfunc main(){}\n"), 0o644); err != nil { + t.Fatal(err) + } + + run := func(args ...string) { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=Test", + "GIT_AUTHOR_EMAIL=test@example.com", + "GIT_COMMITTER_NAME=Test", + "GIT_COMMITTER_EMAIL=test@example.com", + ) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v failed: %v\n%s", args, err, out) + } + } + + run("init") + run("remote", "add", "origin", "https://github.com/example/foo.git") + run("add", ".") + run("commit", "-m", "init") + + res, err := Generate(context.Background(), Options{ + RepoDir: dir, + SyntaxImage: "ghcr.io/project-dalec/dalec/frontend:latest", + PreferGitSource: true, + SourceMode: "context", + }) + if err != nil { + t.Fatal(err) + } + s := string(res.YAML) + if !strings.Contains(s, "git:") { + t.Fatalf("expected git source in YAML:\n%s", s) + } + if !strings.Contains(s, "https://github.com/example/foo") { + t.Fatalf("expected git remote in YAML:\n%s", s) + } +} + +func TestGenerate_GoLibraryRepo_FallsBackWithoutBinaryArtifacts(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module example.com/libfoo\ngo 1.25\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "lib.go"), []byte("package libfoo\nfunc Hello() string { return \"hi\" }\n"), 0o644); err != nil { + t.Fatal(err) + } + + res, err := Generate(context.Background(), Options{RepoDir: dir, SyntaxImage: "ghcr.io/project-dalec/dalec/frontend:latest"}) + if err != nil { + t.Fatal(err) + } + if res.Plan == nil { + t.Fatal("expected plan") + } + if res.Spec == nil { + t.Fatal("expected spec") + } +} + +func TestGenerate_MixedRustNodeWrapperRepo_PrefersRustBaseline(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "Cargo.toml"), []byte("[package]\nname = \"sentry-cli\"\nversion = \"1.0.0\"\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{ + "name": "sentry-cli", + "version": "1.0.0", + "bin": {"sentry-cli": "bin/sentry-cli.js"}, + "scripts": {"build": "cargo build --release"} +}`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(dir, "bin"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "bin", "sentry-cli.js"), []byte("#!/usr/bin/env node\n"), 0o644); err != nil { + t.Fatal(err) + } + + res, err := Generate(context.Background(), Options{RepoDir: dir, SyntaxImage: "ghcr.io/project-dalec/dalec/frontend:latest"}) + if err != nil { + t.Fatal(err) + } + if res.DetectedType != "rust" { + t.Fatalf("expected rust detected type, got %q", res.DetectedType) + } + if res.Plan == nil { + t.Fatal("expected plan") + } + if !strings.HasPrefix(res.Plan.BuildStyle, "rust") { + t.Fatalf("expected rust build style, got %q", res.Plan.BuildStyle) + } + if res.Plan.Intent == IntentWindowsCross { + t.Fatalf("did not expect windowscross intent for mixed rust/node wrapper repo") + } + if !strings.Contains(string(res.YAML), "cargo build") { + t.Fatalf("expected cargo build in emitted YAML:\n%s", string(res.YAML)) + } +} + +func TestGenerate_WindowsMentionsAloneDoNotForceWindowsCross(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "Cargo.toml"), []byte("[package]\nname = \"uv\"\nversion = \"1.0.0\"\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("Install on Windows using uv.exe if you want, but this repo is not a dedicated cross-compile package.\n"), 0o644); err != nil { + t.Fatal(err) + } + + res, err := Generate(context.Background(), Options{RepoDir: dir, SyntaxImage: "ghcr.io/project-dalec/dalec/frontend:latest"}) + if err != nil { + t.Fatal(err) + } + if res.Plan == nil { + t.Fatal("expected plan") + } + if res.Plan.Intent == IntentWindowsCross { + t.Fatalf("did not expect windowscross intent from weak windows mentions") + } +} + +func TestGenerate_ExplicitWindowsCrossSignals_SelectWindowsCross(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module example.com/winctl\ngo 1.25\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(dir, "cmd", "winctl"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "cmd", "winctl", "main.go"), []byte("package main\nfunc main(){}\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "Makefile"), []byte("build-windows:\n\tGOOS=windows GOARCH=amd64 go build -o bin/winctl.exe ./cmd/winctl\n"), 0o644); err != nil { + t.Fatal(err) + } + + res, err := Generate(context.Background(), Options{RepoDir: dir, SyntaxImage: "ghcr.io/project-dalec/dalec/frontend:latest"}) + if err != nil { + t.Fatal(err) + } + if res.Plan == nil { + t.Fatal("expected plan") + } + if res.Plan.Intent != IntentWindowsCross { + t.Fatalf("expected windowscross intent, got %q", res.Plan.Intent) + } + if res.Plan.TargetFamily != TargetFamilyWindows { + t.Fatalf("expected windows target family, got %q", res.Plan.TargetFamily) + } +} + +func TestGenerate_RustCliRepo_DefaultsToPackageIntent(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "Cargo.toml"), []byte("[package]\nname = \"uv\"\nversion = \"1.0.0\"\n"), 0o644); err != nil { + t.Fatal(err) + } + + res, err := Generate(context.Background(), Options{RepoDir: dir, SyntaxImage: "ghcr.io/project-dalec/dalec/frontend:latest"}) + if err != nil { + t.Fatal(err) + } + if res.Plan == nil { + t.Fatal("expected plan") + } + if res.Plan.Intent != IntentPackage { + t.Fatalf("expected package intent for rust cli baseline, got %q", res.Plan.Intent) + } +} + +func TestGenerate_RustCliRepo_WithDockerfile_StillDefaultsToPackageIntent(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "Cargo.toml"), []byte("[package]\nname = \"uv\"\nversion = \"1.0.0\"\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "Dockerfile"), []byte("FROM alpine:3.20\nCOPY . /src\n"), 0o644); err != nil { + t.Fatal(err) + } + + res, err := Generate(context.Background(), Options{RepoDir: dir, SyntaxImage: "ghcr.io/project-dalec/dalec/frontend:latest"}) + if err != nil { + t.Fatal(err) + } + if res.Plan == nil { + t.Fatal("expected plan") + } + if res.Plan.Intent != IntentPackage { + t.Fatalf("expected package intent for rust cli repo with Dockerfile, got %q", res.Plan.Intent) + } +} + +func TestGenerate_NodeHomepagePrefersRepositoryOverInstallScript(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{ + "name": "ruff", + "version": "1.0.0", + "homepage": "https://astral.sh/ruff/install.sh", + "repository": {"type": "git", "url": "https://github.com/astral-sh/ruff.git"}, + "bin": {"ruff": "bin/ruff.js"} +}`), 0o644); err != nil { + t.Fatal(err) + } + + res, err := Generate(context.Background(), Options{RepoDir: dir, SyntaxImage: "ghcr.io/project-dalec/dalec/frontend:latest"}) + if err != nil { + t.Fatal(err) + } + if res.Spec == nil { + t.Fatal("expected spec") + } + if res.Spec.Website != "https://github.com/astral-sh/ruff" { + t.Fatalf("expected repository URL to win over install script homepage, got %q", res.Spec.Website) + } +} + +func TestGenerate_GoWindowsHintsRequireMultipleSignals(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module example.com/nerdctl\ngo 1.25\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(dir, "cmd", "nerdctl"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "cmd", "nerdctl", "main.go"), []byte("package main\nfunc main(){}\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("Supports Windows too.\n"), 0o644); err != nil { + t.Fatal(err) + } + + res, err := Generate(context.Background(), Options{RepoDir: dir, SyntaxImage: "ghcr.io/project-dalec/dalec/frontend:latest"}) + if err != nil { + t.Fatal(err) + } + if res.Plan == nil { + t.Fatal("expected plan") + } + if res.Plan.Intent == IntentWindowsCross { + t.Fatalf("did not expect windowscross intent from weak Go windows hints") + } +} + +func TestDefaultConfigInstallPath_NormalizesEtcPrefix(t *testing.T) { + if got := defaultConfigInstallPath("contrib/Dockerfile.test.d/cri-in-userns/etc_containerd_config.toml"); got != "/etc/containerd/config.toml" { + t.Fatalf("unexpected normalized config path: %q", got) + } +} + +func TestBinaryOutputPathFromArtifact_DoesNotDoubleExe(t *testing.T) { + art := PlannedArtifact{Kind: "binary", Path: "src/bin/nerdctl.exe", Name: "nerdctl"} + if got := binaryOutputPathFromArtifact(art, "nerdctl.exe"); got != "bin/nerdctl.exe" { + t.Fatalf("unexpected binary output path: %q", got) + } +} + +func TestGenerate_GoMultiPlatformMatrix_DoesNotForceWindowsCross(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module example.com/nerdctl\ngo 1.25\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(dir, "cmd", "nerdctl"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "cmd", "nerdctl", "main.go"), []byte("package main\nfunc main(){}\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "Makefile"), []byte("lint:\n\tGOOS=linux make lint-go \\\n\t&& GOOS=windows make lint-go \\\n\t&& GOOS=darwin make lint-go\n"), 0o644); err != nil { + t.Fatal(err) + } + + res, err := Generate(context.Background(), Options{RepoDir: dir, SyntaxImage: "ghcr.io/project-dalec/dalec/frontend:latest"}) + if err != nil { + t.Fatal(err) + } + if res.Plan == nil { + t.Fatal("expected plan") + } + if res.Plan.Intent == IntentWindowsCross { + t.Fatalf("did not expect windowscross intent from multi-platform GOOS matrix") + } +} + +func TestGenerate_CargoRepositoryBeatsInstallScriptHomepage(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "Cargo.toml"), []byte("[package]\nname = \"uv\"\nversion = \"1.0.0\"\nhomepage = \"https://astral.sh/uv/install.sh\"\nrepository = \"https://github.com/astral-sh/uv\"\n"), 0o644); err != nil { + t.Fatal(err) + } + + res, err := Generate(context.Background(), Options{RepoDir: dir, SyntaxImage: "ghcr.io/project-dalec/dalec/frontend:latest"}) + if err != nil { + t.Fatal(err) + } + if res.Spec == nil { + t.Fatal("expected spec") + } + if res.Spec.Website != "https://github.com/astral-sh/uv" { + t.Fatalf("expected repository URL to beat install script homepage, got %q", res.Spec.Website) + } +} + +func TestGenerate_UserIntentOverridesComponentPackageAndBuildTarget(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module example.com/keda\ngo 1.25\n"), 0o644); err != nil { + t.Fatal(err) + } + for _, name := range []string{"adapter", "operator", "webhooks"} { + if err := os.MkdirAll(filepath.Join(dir, "cmd", name), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "cmd", name, "main.go"), []byte("package main\nfunc main(){}\n"), 0o644); err != nil { + t.Fatal(err) + } + } + + res, err := Generate(context.Background(), Options{ + RepoDir: dir, + SyntaxImage: "ghcr.io/project-dalec/dalec/frontend:latest", + MainComponent: "operator", + PackageName: "keda-operator", + BinaryName: "keda-operator", + BuildTarget: "./cmd/operator", + BuildStyle: "go-simple", + Entrypoint: "keda-operator", + Command: "--help", + }) + if err != nil { + t.Fatal(err) + } + if res.Plan == nil { + t.Fatal("expected plan") + } + if res.Plan.MainComponent != "operator" { + t.Fatalf("expected operator main component, got %q", res.Plan.MainComponent) + } + if res.Plan.PackageName != "keda-operator" { + t.Fatalf("expected keda-operator package name, got %q", res.Plan.PackageName) + } + if res.Plan.PrimaryBinaryName != "keda-operator" { + t.Fatalf("expected keda-operator primary binary name, got %q", res.Plan.PrimaryBinaryName) + } + if res.Plan.PrimaryBuildTarget != "./cmd/operator" { + t.Fatalf("expected explicit build target, got %q", res.Plan.PrimaryBuildTarget) + } + s := string(res.YAML) + if !strings.Contains(s, "go build -trimpath -o bin/keda-operator ./cmd/operator") { + t.Fatalf("expected explicit build target and binary name in YAML:\n%s", s) + } + if !strings.Contains(s, "/usr/bin/keda-operator") { + t.Fatalf("expected tests/artifacts to use explicit binary name:\n%s", s) + } +} + +func TestGenerate_PlanRecordsUserIntent(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module example.com/foo\ngo 1.25\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(dir, "cmd", "foo"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "cmd", "foo", "main.go"), []byte("package main\nfunc main(){}\n"), 0o644); err != nil { + t.Fatal(err) + } + + res, err := Generate(context.Background(), Options{ + RepoDir: dir, + SyntaxImage: "ghcr.io/project-dalec/dalec/frontend:latest", + MainComponent: "foo", + PackageName: "fooctl", + BuildStyle: "go-simple", + }) + if err != nil { + t.Fatal(err) + } + if res.Plan == nil || res.Plan.UserIntent == nil { + t.Fatal("expected user intent to be recorded in the plan") + } + fields := strings.Join(res.Plan.UserIntent.ExplicitFields, ",") + if !strings.Contains(fields, "main_component") { + t.Fatalf("expected main_component in explicit fields, got %#v", res.Plan.UserIntent.ExplicitFields) + } + if !strings.Contains(fields, "package_name") { + t.Fatalf("expected package_name in explicit fields, got %#v", res.Plan.UserIntent.ExplicitFields) + } + if !strings.Contains(fields, "build_style") { + t.Fatalf("expected build_style in explicit fields, got %#v", res.Plan.UserIntent.ExplicitFields) + } +} diff --git a/internal/specgen/toolchain.go b/internal/specgen/toolchain.go new file mode 100644 index 000000000..4a894861d --- /dev/null +++ b/internal/specgen/toolchain.go @@ -0,0 +1,156 @@ +package specgen + +import ( + "fmt" + "strconv" + "strings" +) + +// GoToolchainRequirement describes what Go toolchain a repo needs and how to +// satisfy it on a given target. +type GoToolchainRequirement struct { + // MinVersion is the minimum Go version required, e.g. "1.22", "1.25.0". + // Parsed from the go directive in go.mod. + MinVersion string + + // MajorMinor is the normalised "X.Y" form used for package name lookups. + // e.g. "1.25.0" → "1.25", "1.22" → "1.22". + MajorMinor string + + // Major is the integer major version (always 1 for current Go). + Major int + // Minor is the integer minor version, e.g. 25 for Go 1.25. + Minor int + + // NeedsNewer is true when the required version exceeds what the standard + // distro package manager provides on common targets (jammy, azlinux3, noble). + // When true the caller should emit a target-specific golang package override. + NeedsNewer bool +} + +// ParseGoToolchainRequirement parses a go.mod version string (e.g. "1.25", +// "1.25.0", "1.25.0") and returns a populated GoToolchainRequirement. +// Returns a zero-value struct if the version is empty or unparseable. +func ParseGoToolchainRequirement(goDirective string) GoToolchainRequirement { + v := strings.TrimSpace(goDirective) + if v == "" { + return GoToolchainRequirement{} + } + + // Normalise: strip patch component for major.minor extraction. + parts := strings.Split(v, ".") + if len(parts) < 2 { + return GoToolchainRequirement{MinVersion: v} + } + + major, err := strconv.Atoi(parts[0]) + if err != nil { + return GoToolchainRequirement{MinVersion: v} + } + minor, err := strconv.Atoi(parts[1]) + if err != nil { + return GoToolchainRequirement{MinVersion: v} + } + + majorMinor := fmt.Sprintf("%d.%d", major, minor) + + // Version thresholds for common targets (conservative — use the lowest + // known-good version each distro ships): + // azlinux3 / CBL-Mariner 3: golang ≈ 1.22 + // jammy / Ubuntu 22.04: golang-go ≈ 1.18 + // noble / Ubuntu 24.04: golang ≈ 1.22 + // + // If the repo needs anything above 1.22 we flag it as NeedsNewer so the + // plan can emit target-specific overrides. + const maxStandardMinor = 22 + needsNewer := major > 1 || (major == 1 && minor > maxStandardMinor) + + return GoToolchainRequirement{ + MinVersion: v, + MajorMinor: majorMinor, + Major: major, + Minor: minor, + NeedsNewer: needsNewer, + } +} + +// GoToolchainDepForTarget returns the package name and optional version +// constraint string for the Go toolchain on a given dalec target. +// +// dalec PackageConstraints version strings use the same syntax as the +// underlying package manager: +// - RPM targets (azlinux3): ">= 1.22.0" +// - DEB targets (jammy, noble): ">= 1.22~" +// +// When the required version exceeds what the standard distro provides, the +// Microsoft Go package (msft-golang) is preferred on azlinux3 because it +// ships a current toolchain. On deb targets we fall back to recommending the +// golang-1.X-go naming convention used by Ubuntu PPA / deadsnakes style. +func GoToolchainDepForTarget(req GoToolchainRequirement, targetName string) (pkgName string, versionConstraint string) { + if req.MajorMinor == "" { + // No version information — emit bare "golang" and let the distro decide. + return "golang", "" + } + + low := strings.ToLower(strings.TrimSpace(targetName)) + + switch { + // ── azlinux3 / Mariner / CBL ────────────────────────────────────────────── + case strings.Contains(low, "azlinux") || strings.Contains(low, "mariner") || strings.Contains(low, "cbl"): + if req.NeedsNewer { + // msft-golang ships a current Go toolchain on Mariner/AzureLinux. + // It installs as /usr/local/go and sets PATH — compatible with dalec. + return "msft-golang", "" + } + // Standard golang package with a floor version constraint. + return "golang", fmt.Sprintf(">= %s.0", req.MajorMinor) + + // ── Ubuntu noble (24.04) ────────────────────────────────────────────────── + case strings.Contains(low, "noble"): + if req.NeedsNewer { + // golang-go on noble is 1.22. For anything higher use the golang-X.Y-go + // naming convention available via Ubuntu toolchain PPA, or flag it. + return fmt.Sprintf("golang-%s-go", req.MajorMinor), "" + } + return "golang", fmt.Sprintf(">= %s~", req.MajorMinor) + + // ── Ubuntu jammy (22.04) ────────────────────────────────────────────────── + case strings.Contains(low, "jammy"): + if req.NeedsNewer { + // jammy ships Go 1.18. Anything higher needs golang-X.Y-go from the + // Ubuntu toolchain PPA. Emit the versioned package name so the spec + // is reviewable; the user must ensure the PPA is configured. + return fmt.Sprintf("golang-%s-go", req.MajorMinor), "" + } + return "golang", fmt.Sprintf(">= %s~", req.MajorMinor) + + // ── Windows cross ───────────────────────────────────────────────────────── + case strings.Contains(low, "windows"): + if req.NeedsNewer { + return "msft-golang", "" + } + return "golang", fmt.Sprintf(">= %s.0", req.MajorMinor) + + // ── Unknown / generic fallback ──────────────────────────────────────────── + default: + if req.NeedsNewer { + // Can't know the right package name. Emit the generic "golang" with a + // version floor and add an unresolved item (handled by the caller). + return "golang", fmt.Sprintf(">= %s", req.MajorMinor) + } + return "golang", fmt.Sprintf(">= %s", req.MajorMinor) + } +} + +// IsGoVersionAtLeast returns true when version v is >= major.minor. +// v is in the form "1.25", "1.25.0", etc. +func IsGoVersionAtLeast(v string, major, minor int) bool { + req := ParseGoToolchainRequirement(v) + if req.MajorMinor == "" { + return false + } + if req.Major != major { + return req.Major > major + } + return req.Minor >= minor +} diff --git a/internal/specgen/types.go b/internal/specgen/types.go new file mode 100644 index 000000000..d833cd7e5 --- /dev/null +++ b/internal/specgen/types.go @@ -0,0 +1,163 @@ +package specgen + +type Analysis struct { + Facts *RepoFacts `json:"facts,omitempty"` + Metadata MetadataHint `json:"metadata"` + Languages []string `json:"languages,omitempty"` + BuildDrivers []string `json:"build_drivers,omitempty"` + PackageHints []string `json:"package_hints,omitempty"` + RepoShape RepoShape `json:"repo_shape"` + Runtime RuntimeHint `json:"runtime"` + Artifacts []ArtifactHint `json:"artifacts,omitempty"` + SelectedStrategy string `json:"selected_strategy"` + SelectedRuntimeKind string `json:"selected_runtime_kind"` + Confidence ConfidenceReport `json:"confidence"` + Unresolved []UnresolvedItem `json:"unresolved,omitempty"` + Evidence []Evidence `json:"evidence,omitempty"` + + InstallLayout InstallLayout `json:"install_layout,omitempty"` + Decisions []DecisionRecord `json:"decisions,omitempty"` + Alternatives *Alternatives `json:"alternatives,omitempty"` + + // Transitional / legacy fields kept for compatibility with the rest of the + // current tree until we update analysis.go / plan_deterministic.go / build.go. + CandidateComponents []string `json:"candidate_components,omitempty"` + Services []ServiceHint `json:"services,omitempty"` + ManpagePaths []string `json:"manpage_paths,omitempty"` + ConfigPaths []string `json:"config_paths,omitempty"` + CICommands []string `json:"ci_commands,omitempty"` + InstallHints []string `json:"install_hints,omitempty"` + + BuildHints []CommandHint `json:"build_hints,omitempty"` + TestHints []CommandHint `json:"test_hints,omitempty"` + InstallHints2 []CommandHint `json:"install_hints_v2,omitempty"` + DocHints []CommandHint `json:"doc_hints,omitempty"` + Components []ComponentHint `json:"components,omitempty"` + MakeTargets []MakeTargetHint `json:"make_targets,omitempty"` +} + +type MetadataHint struct { + Name string `json:"name"` + Description string `json:"description"` + Website string `json:"website,omitempty"` + License string `json:"license"` + Version string `json:"version"` +} + +type RepoShape struct { + IsMonorepo bool `json:"is_monorepo"` + HasWorkspace bool `json:"has_workspace"` + HasMultipleBins bool `json:"has_multiple_bins"` + HasMultipleApps bool `json:"has_multiple_apps"` + PrimarySubdir string `json:"primary_subdir,omitempty"` + CandidateSubdirs []string `json:"candidate_subdirs,omitempty"` +} + +type RuntimeHint struct { + Kind string `json:"kind"` // cli|daemon|library|unknown + Entrypoint string `json:"entrypoint,omitempty"` + Cmd string `json:"cmd,omitempty"` + Confidence string `json:"confidence"` // high|medium|low +} + +type ArtifactHint struct { + Kind string `json:"kind"` // binary|docs|wheel|data|manpage|config|libexec|unknown + Name string `json:"name,omitempty"` + Path string `json:"path,omitempty"` + Confidence string `json:"confidence"` // high|medium|low + Reason string `json:"reason,omitempty"` +} + +type ConfidenceReport struct { + Metadata string `json:"metadata"` // high|medium|low + Strategy string `json:"strategy"` // high|medium|low + Runtime string `json:"runtime"` // high|medium|low + Overall string `json:"overall"` // high|medium|low +} + +type Evidence struct { + Kind string `json:"kind"` // manifest|ci|buildfile|readme|heuristic|file-path + Source string `json:"source"` // filename / detector + Detail string `json:"detail"` + Confidence string `json:"confidence,omitempty"` // high|medium|low +} + +type UnresolvedItem struct { + Code string `json:"code"` + Message string `json:"message"` + Severity string `json:"severity"` // high|medium|low + Suggestions []string `json:"suggestions,omitempty"` +} + +type ServiceHint struct { + Name string `json:"name,omitempty"` + UnitPath string `json:"unit_path,omitempty"` + ConfigPath string `json:"config_path,omitempty"` + Confidence string `json:"confidence,omitempty"` + Reason string `json:"reason,omitempty"` +} + +type CommandHint struct { + Command string `json:"command"` + Source string `json:"source,omitempty"` // Makefile, workflow file, etc. + Kind string `json:"kind,omitempty"` // build|test|install|doc|release + Confidence string `json:"confidence,omitempty"` // high|medium|low + Reason string `json:"reason,omitempty"` +} + +type ComponentHint struct { + Name string `json:"name"` + Path string `json:"path,omitempty"` + Language string `json:"language,omitempty"` + Role string `json:"role,omitempty"` // cli|daemon|library|tool + Confidence string `json:"confidence,omitempty"` // high|medium|low + Reason string `json:"reason,omitempty"` +} + +type MakeTargetHint struct { + Name string `json:"name"` + Commands []string `json:"commands,omitempty"` + Confidence string `json:"confidence,omitempty"` + Reason string `json:"reason,omitempty"` +} + +type DecisionRecord struct { + Field string `json:"field"` + Chosen string `json:"chosen"` + Confidence string `json:"confidence,omitempty"` // high|medium|low + Reason string `json:"reason,omitempty"` + Evidence []string `json:"evidence,omitempty"` +} + +type ScoredChoice struct { + Value string `json:"value"` + Score int `json:"score"` + Confidence string `json:"confidence,omitempty"` // high|medium|low + Reason string `json:"reason,omitempty"` + Evidence []string `json:"evidence,omitempty"` +} + +type Alternatives struct { + Components []ScoredChoice `json:"components,omitempty"` + BuildStyles []ScoredChoice `json:"build_styles,omitempty"` + EntryPoints []ScoredChoice `json:"entrypoints,omitempty"` + PackageNames []ScoredChoice `json:"package_names,omitempty"` +} + +type InstallLayout struct { + Binaries []PathEvidence `json:"binaries,omitempty"` + Manpages []PathEvidence `json:"manpages,omitempty"` + ConfigFiles []PathEvidence `json:"config_files,omitempty"` + Docs []PathEvidence `json:"docs,omitempty"` + DataDirs []PathEvidence `json:"data_dirs,omitempty"` + Libexec []PathEvidence `json:"libexec,omitempty"` + Systemd []PathEvidence `json:"systemd,omitempty"` +} + +type PathEvidence struct { + Path string `json:"path"` + Kind string `json:"kind"` // binary|manpage|config|doc|data_dir|libexec|systemd + Source string `json:"source,omitempty"` // detector / file / manifest / ci / makefile + Confidence string `json:"confidence,omitempty"` // high|medium|low + Reason string `json:"reason,omitempty"` +} diff --git a/internal/specgen/validate.go b/internal/specgen/validate.go new file mode 100755 index 000000000..d36d92df3 --- /dev/null +++ b/internal/specgen/validate.go @@ -0,0 +1,291 @@ +package specgen + +import ( + "fmt" + "strings" + + "github.com/project-dalec/dalec" +) + +func validateSpecMinimal(spec *dalec.Spec) error { + return validateSpecStructural(spec) +} + +func validateSpecStructural(spec *dalec.Spec) error { + if spec == nil { + return fmt.Errorf("spec is nil") + } + if strings.TrimSpace(spec.Name) == "" { + return fmt.Errorf("spec name is empty") + } + if len(spec.Sources) == 0 { + return fmt.Errorf("spec has no sources") + } + + for name, src := range spec.Sources { + if strings.TrimSpace(name) == "" { + return fmt.Errorf("spec has a source with empty name") + } + if src.Context == nil && src.Path == "" && len(src.Generate) == 0 { + return fmt.Errorf("source %q appears empty", name) + } + } + + for i, s := range spec.Build.Steps { + if strings.TrimSpace(s.Command) == "" { + return fmt.Errorf("build step %d has empty command", i) + } + } + + for _, test := range spec.Tests { + if strings.TrimSpace(test.Name) == "" { + return fmt.Errorf("test name is empty") + } + for i, step := range test.Steps { + if strings.TrimSpace(step.Command) == "" { + return fmt.Errorf("test %q step %d has empty command", test.Name, i) + } + } + } + + return nil +} + +func validateSpecSemantic(spec *dalec.Spec, plan *SpecPlan) error { + if spec == nil { + return fmt.Errorf("spec is nil") + } + + if strings.TrimSpace(spec.Version) == "" { + return fmt.Errorf("spec version is empty") + } + if strings.TrimSpace(spec.Revision) == "" { + return fmt.Errorf("spec revision is empty") + } + + for _, step := range spec.Build.Steps { + if looksDangerous(step.Command) { + return fmt.Errorf("unsafe build command detected") + } + } + + for _, test := range spec.Tests { + for _, step := range test.Steps { + if looksDangerous(step.Command) { + return fmt.Errorf("unsafe test command detected in %q", test.Name) + } + } + } + + if plan != nil && plan.Intent == IntentContainerOnly { + if err := validateContainerOnlyShape(spec); err != nil { + return err + } + return validateFileBasedTests(spec) + } + + if hasDeclaredBinaryArtifacts(spec) { + if err := validateBinaryArtifacts(spec); err != nil { + return err + } + } + + if err := validateGoStyleSmokeTests(spec); err != nil { + return err + } + if err := validateFileBasedTests(spec); err != nil { + return err + } + + if strings.TrimSpace(spec.Name) == "" || strings.TrimSpace(spec.Version) == "" { + return fmt.Errorf("invalid spec metadata") + } + + return nil +} + +func validatePlan(plan *SpecPlan) error { + if plan == nil { + return fmt.Errorf("plan is nil") + } + if plan.Intent == "" { + return fmt.Errorf("plan intent is empty") + } + if plan.TargetFamily == "" { + return fmt.Errorf("plan target family is empty") + } + if plan.Intent == IntentWindowsCross && plan.TargetFamily != TargetFamilyWindows { + return fmt.Errorf("windowscross intent requires windows target family") + } + + // Container-only plans are allowed to have an empty build style at plan + // time — BuildSpec will treat them as pure container assembly. + // Normalise rather than reject so that sparse analysis paths don't fail here. + if plan.Intent == IntentContainerOnly && strings.TrimSpace(plan.BuildStyle) == "" { + plan.BuildStyle = "container-assembly" + } + + if plan.UseTargets && len(plan.Routes) == 0 { + return fmt.Errorf("plan uses targets but has no routes") + } + if strings.TrimSpace(plan.PackageName) == "" { + return fmt.Errorf("plan package name is empty") + } + if strings.TrimSpace(plan.MainComponent) == "" && plan.Intent != IntentContainerOnly { + return fmt.Errorf("plan main component is empty") + } + return nil +} + +func validateFinalSpec(spec *dalec.Spec, plan *SpecPlan) error { + if err := validateSpecStructural(spec); err != nil { + return err + } + if err := validateSpecSemantic(spec, plan); err != nil { + return err + } + return nil +} + +func validateContainerOnlyShape(spec *dalec.Spec) error { + if spec == nil { + return nil + } + + // Container-only baseline is allowed to have no build steps. + for i, step := range spec.Build.Steps { + if strings.TrimSpace(step.Command) == "" { + return fmt.Errorf("container-only spec has empty build step %d", i) + } + } + + return nil +} + +func hasDeclaredBinaryArtifacts(spec *dalec.Spec) bool { + if spec == nil { + return false + } + return len(spec.Artifacts.Binaries) > 0 +} + +func validateBinaryArtifacts(spec *dalec.Spec) error { + if spec == nil || len(spec.Artifacts.Binaries) == 0 { + return nil + } + + for p := range spec.Artifacts.Binaries { + pp := normalizeArtifactPath(strings.TrimSpace(p)) + if pp == "" { + return fmt.Errorf("binary artifact path is empty") + } + if strings.Contains(strings.ToLower(pp), "") { + return fmt.Errorf("binary artifact path contains placeholder value") + } + if !binaryArtifactPathAllowed(pp, spec) { + return fmt.Errorf("binary artifact path %q does not look consistent with the baseline build layout", pp) + } + } + + return nil +} + +func binaryArtifactPathAllowed(path string, spec *dalec.Spec) bool { + path = normalizeArtifactPath(path) + low := strings.ToLower(path) + + // Preferred normalized native layout (covers both regular and .exe variants). + if strings.HasPrefix(low, "src/bin/") { + return true + } + + // Common cargo layout (covers both regular and .exe variants). + if strings.HasPrefix(low, "src/target/release/") { + return true + } + + // Python dist-style fallback. + if strings.HasPrefix(low, "src/dist/") { + return true + } + + // Legacy / make-driven fallback. + if strings.HasPrefix(low, "src/.out/") { + return true + } + + // If the build explicitly writes the same relative output path, allow it. + if buildStepsReferenceArtifact(path, spec) { + return true + } + + return false +} + +func buildStepsReferenceArtifact(artifactPath string, spec *dalec.Spec) bool { + if spec == nil { + return false + } + + artifactPath = normalizeArtifactPath(artifactPath) + trimmed := strings.TrimPrefix(artifactPath, "src/") + trimmed = strings.TrimPrefix(trimmed, "./") + trimmedLow := strings.ToLower(trimmed) + fullLow := strings.ToLower(artifactPath) + + for _, step := range spec.Build.Steps { + cmd := strings.ToLower(strings.TrimSpace(step.Command)) + if cmd == "" { + continue + } + + if strings.Contains(cmd, fullLow) { + return true + } + if trimmedLow != "" && strings.Contains(cmd, trimmedLow) { + return true + } + } + + return false +} + +func validateGoStyleSmokeTests(spec *dalec.Spec) error { + if spec == nil || len(spec.Tests) == 0 { + return nil + } + + for _, test := range spec.Tests { + for _, step := range test.Steps { + cmd := strings.TrimSpace(step.Command) + if strings.HasPrefix(cmd, "./bin/") && strings.TrimSpace(test.Dir) == "" { + return fmt.Errorf("test %q executes ./bin/... but has no dir set", test.Name) + } + if strings.HasPrefix(cmd, "./bin/") && strings.HasSuffix(cmd, ".exe") && strings.TrimSpace(test.Dir) == "" { + return fmt.Errorf("test %q executes ./bin/*.exe but has no dir set", test.Name) + } + } + } + + return nil +} + +func validateFileBasedTests(spec *dalec.Spec) error { + if spec == nil || len(spec.Tests) == 0 { + return nil + } + + for _, test := range spec.Tests { + for path := range test.Files { + p := strings.TrimSpace(path) + if p == "" { + return fmt.Errorf("test %q has empty file assertion path", test.Name) + } + if !strings.HasPrefix(p, "/") { + return fmt.Errorf("test %q has non-absolute file assertion path %q", test.Name, p) + } + } + } + + return nil +}