diff --git a/.mcp.json b/.mcp.json index 05ffffb..3ddcf81 100644 --- a/.mcp.json +++ b/.mcp.json @@ -3,7 +3,7 @@ "fslangmcp": { "type": "stdio", "command": "fslangmcp", - "args": ["--project", "Partas.Testing.slnx"], + "args": ["--project", "Partas.Build.slnx"], "env": {} } } diff --git a/Build.fsproj b/Build.fsproj index 583cbcd..6da58bc 100644 --- a/Build.fsproj +++ b/Build.fsproj @@ -26,13 +26,7 @@ `Update` modifies the existing implicit reference instead of adding a duplicate (which would trigger NU1504). --> - - - - - - diff --git a/Build/Program.fs b/Build/Program.fs index a5daefa..b75a6c5 100644 --- a/Build/Program.fs +++ b/Build/Program.fs @@ -1,4 +1,4 @@ -/// +/// /// The build CLI, written against Partas.Build itself. /// /// A step is a stage of a pipeline, and a stage that needs a flag binds it in an @@ -11,16 +11,9 @@ module Build open System open System.IO -open Fake.Core.Context -open Fake.IO -open Fake.IO.Globbing.Operators open Partas.Build -open Partas.Build.Internal open Partas.TypeProvider.BuildHelper -let execContext = FakeExecutionContext.Create false "build.fsx" [] -setExecutionContext (RuntimeContext.Fake execContext) - [] let __REPOSITORY_DIRECTORY__ = __SOURCE_DIRECTORY__ @@ -35,31 +28,11 @@ type Repo = let private root = Repo.FileSystem.``.``.ToString() -let formatFiles = - !! "**/*.fs" - -- "**/obj/**/*.*" - -- "**/AssemblyInfo.fs" - module Options = - let quick = - Input.option "--quick" - |> Input.alias "-q" - |> Input.desc "Skips restores, installations, formatting etc" - let skipTests = - Input.option "--skip-tests" - |> Input.desc "Skips running tests" let nugetSource = Input.option "--nuget-source" |> Input.def "https://api.nuget.org/v3/index.json" - |> Input.desc "NuGet feed URL for authenticated publishing" - let watch = - Input.option "--watch" - |> Input.desc "Runs the operation in watch mode." - - let config = - Baked.Dotnet.config.option - |> InputSpec.ofInput - |> InputSpec.map (Option.defaultValue "Release") + |> Input.description "NuGet feed URL for authenticated publishing" module Project = let projects = Repo.Project.AllProjects() @@ -88,7 +61,7 @@ module Project = Input.option "--project" |> Input.alias "-p" |> Input.arity Arity.OneOrMore - |> Input.desc "The project(s) to target" + |> Input.description "The project(s) to target" |> Input.allowMultipleArgumentsPerToken |> Input.mapFromManyWith StringComparer.OrdinalIgnoreCase [ yield! allProjects @@ -97,116 +70,69 @@ module Project = /// Stages every command opens with. All are skipped by --quick. module Prelude = - let restore = input { - let! quick = Options.quick - return stage "restore" { - when' (not quick) - run "dotnet tool restore --verbosity q" - run (cmd $"dotnet restore {Repo.Project.SolutionFile}") - } - } - let clean = input { - let! quick = Options.quick - - return stage "clean" { - when' (not quick) - - run (fun (_: StageContext) -> - Repo.VirtualFileSystem.bin.``.``.EnumerateFiles("*.nupkg", SearchOption.AllDirectories) - |> Seq.iter (_.ToString() >> Shell.rm) - !! "**/**/bin" - ++ Repo.VirtualFileSystem.tmp.ToString() - -- Repo.VirtualFileSystem.bin.ToString() - |> Shell.cleanDirs - ) - } - } + let restore = Baked.Stages.restore Repo.Project.SolutionFile + /// Empties every bin directory except the root's, and deletes the packages in the root's. + let clean = Baked.Stages.clean [ "**/bin"; "!bin"; "tmp" ] [ "bin/**/*.nupkg" ] module ProjectManagement = - let build (project: InputSpec) = input { - let! config = Options.config - and! project = project - return stage $"build {project}" { - run (cmd $"dotnet build {project} -c {config}") + let private packages = Repo.VirtualFileSystem.bin.ToString() + + // These projects share references and output paths; independent dotnet builds must not race. + let buildAll = + Baked.Stages.build (Project.allProjects |> List.map snd) + |> InputSpec.map (StageContext.toggleParallel false) + let packAll = Baked.Stages.pack packages (Project.allProjects |> List.map snd) + let publishAll = input { + let! source = Options.nugetSource + and! apiKey = Baked.NuGet.apiKey.option + return stage "push" { + let packageGlob = Path.Combine(packages, "*.nupkg") + match apiKey with + | Some key -> + stage "push to source" { + run (cmd $"dotnet nuget push {packageGlob} --source {source}" + |> Cmd.secretOption "--api-key" key + |> Cmd.arg "--skip-duplicate") + } + | None -> stage "push to local feed" { run (cmd $"dotnet nuget push {packageGlob} --source local --skip-duplicate") } } } - let buildAll = stage "build" { - parallel' - for _, project in Project.allProjects do - build (InputSpec.ret project) - } - - let pack (project: InputSpec) = input { - let! project = project - return stage $"pack {project}" { - run (cmd $"dotnet pack {project} --no-restore -o {Repo.VirtualFileSystem.bin.ToString()}") - } - } - let packAll = stage "pack" { - parallel' - for _, project in Project.allProjects do - InputSpec.ret project - |> pack - } - let publish (project: InputSpec) = input { - let! key = Baked.NuGet.apiKey.option - and! source = Options.nugetSource - and! project = project - return stage $"publish {project}" { - stage "local publish" { - when' key.IsNone - echo "Publishing to local feed" - run $"dotnet nuget push {project} --source local --skip-duplicate" - } - whenSome key (fun key -> - stage "nuget publish" { - echo $"Publishing to {source}" - runSensitive - $"dotnet nuget push {project} --source {source} --api-key {key} --skip-duplicate" - }) - } - } - let publishAll = stage "publish" { - Path.Combine(Repo.VirtualFileSystem.bin.ToString(), "*.nupkg") - |> InputSpec.ret - |> publish - } let bumpArgument = Baked.SemVer.Stages.bumpArgument (InputSpec.ofInput Project.target) module Tests = - let buildAll = input { - let! skipTests = Options.skipTests - and! projects = - Project.testProjects - |> List.map (_.Path >> InputSpec.ret >> ProjectManagement.build) - |> InputSpec.sequence - return stage "build tests" { - when' (not skipTests) - projects - } - } + /// Builds the test projects one at a time; skipped by --skip-tests. + let buildAll = + Baked.Stages.buildWith Baked.Dotnet.configOrRelease (Project.testProjects |> List.map _.Path) + |> InputSpec.map (fun build -> { StageContext.toggleParallel false build with Name = "build tests" }) + |> InputSpec.map2 (fun skipTests -> StageContext.addPredicateBecause (ValueSome "--skip-tests is set") (fun _ -> not skipTests)) + (InputSpec.ofInput Baked.Common.skipTests) + + let private expectoArguments = [ "--colours"; "256"; "--sequenced" ] + let execute = input { - let! skipTests = Options.skipTests - and! config = Options.config + let! skipTests = Baked.Common.skipTests and! ci = Baked.Common.isCI + and! suites = + [ Repo.Project.``Partas.Build.Cmd.NetStandard.Tests``.Path + Repo.Project.``Partas.Build.ExternalAnnotations.Tests``.Path + Repo.Project.``Partas.Build.Tests``.Path + Repo.Project.``Partas.ExternalAnnotations.Tests``.Path ] + |> InputSpec.traverse (fun project -> + Baked.Stages.expecto project expectoArguments + |> InputSpec.map (StageContext.setOutput (ValueSome StageOutput.Console))) return stage "test" { - when' (not skipTests) - // Expecto reports assertions on stdout; keep them visible in CI and uploaded logs. + when' (not skipTests) "--skip-tests is set" outputTo StageOutput.Console - // Commands exercised by tests must not append fixture reports to the real job summary. envVars [ "GITHUB_STEP_SUMMARY", "" ] - for project in [ - Repo.Project.``Partas.Build.Cmd.NetStandard.Tests``.Path - Repo.Project.``Partas.Build.ExternalAnnotations.Tests``.Path - Repo.Project.``Partas.Build.Tests``.Path - Repo.Project.``Partas.ExternalAnnotations.Tests``.Path - ] do stage $"test {project}" { - run (Cmd.ofString $"""dotnet run --project {project} --no-build -c {config} -- {if ci then "--summary" else null} --colours 256 --sequenced""") - } - // Runs in both configurations regardless of `--configuration`: Release is what catches FS1118. + suites + // Built by the run, in both configurations regardless of `--configuration`: Release is what catches FS1118. for probeConfig in [ "Debug"; "Release" ] do stage $"compiler probe ({probeConfig})" { - run (Cmd.ofString $"""dotnet run --project {Repo.Project.``Partas.Build.CompilerProbe``.Path} -c {probeConfig} -- {if ci then "--summary" else null} --colours 256 --sequenced""") + run ( + cmd $"dotnet run --project {Repo.Project.``Partas.Build.CompilerProbe``.Path} -c {probeConfig} --" + |> Cmd.argIf ci [ "--summary" ] + |> Cmd.args expectoArguments + ) } } } @@ -214,7 +140,7 @@ module Tests = module Documentation = /// Serves under --watch, builds otherwise. let generate = input { - let! watch = Options.watch + let! watch = Baked.Common.watch and! noHotReload = Input.option "--no-hot-reload" return stage "docs" { run ( @@ -232,9 +158,9 @@ module Documentation = /// Written for fsdocs, which generated both files at the site root as a link inventory under such a heading. /// let llms = input { - let! watch = Options.watch + let! watch = Baked.Common.watch return stage "llms" { - when' (not watch) + when' (not watch) "--watch is set" run (fun ctx -> let header = File.ReadAllText(Path.Combine(root, "docs", "static", "llms.txt")).TrimEnd() for name in [ "llms.txt"; "llms-full.txt" ] do diff --git a/CLAUDE.md b/CLAUDE.md index 1819b06..4cb41d5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,4 +1,4 @@ -# CLAUDE.md +# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co `notes/PLAN-Execution.md` and its checklist `notes/PLAN-Execution-Tasks.md` own the current execution model: typed producers and `consumes`, failure handlers, the scope reports, and the shared `StageSettingsBuilder`/`PipelineSettingsBuilder`. -The one-line version: input collection is **applicative**, not monadic. `InputSpec<'T> = { Inputs: ActionInput list; Read: ParseResult -> 'T }` makes the input set readable without a `ParseResult`, breaking the circularity between registering options and binding their values. Stages are pure (no `ActionContext`); binding happens in an `inputs { let! … and! … return … }` CE, and pipelines/commands harvest `.Inputs` upward. +The one-line version: input collection is **applicative**, not monadic. `InputSpec<'T> = { Inputs: ActionInput list; Read: ParseResult -> 'T }` makes the input set readable without a `ParseResult`, breaking the circularity between registering options and binding their values. Stages are pure (no `ActionContext`); binding happens in an `input { let! … and! … return … }` CE (`inputs` is its obsolete second name), and pipelines/commands harvest `.Inputs` upward. ## Commands @@ -38,28 +38,63 @@ Fast inner loop while working on the library only: `dotnet build src/Partas.Buil ## Current state (verify before assuming) -- Phases 0-7 of `notes/PLAN.md` are done: `dotnet build src/Partas.Build` is clean and `dotnet run --project Build.fsproj -- test` is green (433 Expecto tests across four suites — 291 in `tests/Partas.Build.Tests`, one file per layer, plus 65 in `tests/Partas.Build.ExternalAnnotations.Tests`, 74 in `tests/Partas.ExternalAnnotations.Tests` and 3 in `tests/Partas.Build.Cmd.NetStandard.Tests`; the `test` stage also runs `tests/Partas.Build.CompilerProbe` in both Debug and Release, 11 tests each). The `Build/` CLI is written against the library: a breaking change breaks it first. +- Phases 0-7 of `notes/PLAN.md` are done: `dotnet build src/Partas.Build` is clean and `dotnet run --project Build.fsproj -- test` is green (506 Expecto tests across four suites — 365 in `tests/Partas.Build.Tests`, one file per layer, plus 65 in `tests/Partas.Build.ExternalAnnotations.Tests`, 73 in `tests/Partas.ExternalAnnotations.Tests` (2 more ignored) and 3 in `tests/Partas.Build.Cmd.NetStandard.Tests`; the `test` stage also runs `tests/Partas.Build.CompilerProbe` in both Debug and Release, 11 tests each). The `Build/` CLI is written against the library: a breaking change breaks it first. - The DSL exists end to end: `inputs` (`Builders/Inputs.fs`), `stage` (`Builders/Stage.fs`), `pipeline` (`Builders/Pipeline.fs`), `command`/`rootCommand` (`Builders/Command.fs`). A stage that declares an input turns its pipeline into an `InputSpec`, and the command registers whatever those specs declare. Conditions are in `Builders/Conditions.fs` — `whenAll`/`whenAny`/`whenNot`/`whenEnv`/`whenStage` plus the `when'`/`whenEnvVar`/`whenBranch`/`when{Windows,Linux,OSX}` operations on `StageBuilder`. - A command carries `PipelineDefaults: BuildPipeline` and takes the pipeline-level operations itself (`workingDir`, `envVars`, the three timeouts, `acceptExitCodes`, the output operations, `noPrefixForStep`/`noStdRedirectForStep`, `runBeforeEachStage`/`runAfterEachStage`, `post`, `verbosity`/`verbose`/`quiet`), each one built through `CommandBuilderBase.MapPipelineDefault`. They are **defaults, not overrides**: `PipelineContext.applyDefaults` copies a setting across only where the pipeline left it at the value `PipelineContext.create` gave it, so a pipeline that sets the same thing wins. See *Command defaults* below. -- `run`/`runSensitive` start real processes through `CmdRunner` (`Process.fs`). A `Cmd`, defined in `src/Partas.Build.Cmd/Program.fs`, keeps the executable and its arguments apart all the way to `ProcessStartInfo.ArgumentList`, so the platform does the escaping. Interpolate through the `cmd` helper — `run (cmd $"dotnet build {project}")`. `run $"..."` binds to the `string` overload and flattens the holes; `runSensitive $"..."` takes the `FormattableString` directly and masks every hole as `***`. There is no `Fake.Core.Process` dependency; `notes/PLAN.md`'s *The command runner* records why. +- `run`/`runSensitive` start real processes through `CmdRunner` (`Process.fs`). A `Cmd`, defined in `src/Partas.Build.Cmd/Program.fs`, keeps the executable and its arguments apart all the way to `ProcessStartInfo.ArgumentList`, so the platform does the escaping. Interpolate through the `cmd` helper — `run (cmd $"dotnet build {project}")`. `run $"..."` binds to the `string` overload and flattens the holes; `runSensitive $"..."` takes the `FormattableString` directly and masks every hole as `***`. A function returning a command line is `runLine (fun ctx -> "...")`; the `run` overloads over `StageContext -> string` (and its `Async`/`Task`/`option` forms) are `[]`, since a lambda returning a message would start a process. `runLine` is a separate name, so the `run` overload set — and the `FS0041` balance below — is unchanged. There is no `Fake.Core.Process` dependency; `notes/PLAN.md`'s *The command runner* records why. - Fun.Build's `Mode` (`Execution | CommandHelp | Verification`) has **not** been ported. `PipelineContext.Verify` is a placeholder and `buildPipelineVerification` is commented out. `CommandHelp` is redundant: System.CommandLine generates help. Whether `Verification` survives is an open question in `notes/PLAN.md`. - `PipelineContext.run` is complete enough to execute stages, post stages, timeouts, parallelism and cancellation. ## Architecture notes -Compile order in `Partas.Build.fsproj` matters (F#): `System.CommandLine/Aliases.fs` → `System.CommandLine/Inputs.fs` → `Exceptions.fs` → `Output.fs` → `Environment.fs` → `Timing.fs` → `Producer.fs` → `Failures.fs` → `Conductors.fs` → `Conductors.Runners.fs` → `Process.fs` → `Operations.fs` → `Dependencies.fs` → `DependencyPlan.fs` → `ExecutionState.fs` → `Builders/StageSettings.fs` → `Builders/Stage.fs` → `Builders/Conditions.fs` → `Builders/PipelineSettings.fs` → `Builders/Pipeline.fs` → `Builders/Inputs.fs` → `Explain.fs` → `Summary.fs` → `Builders/Command.fs`. The batteries-included layer is its own project, `src/Partas.Build.Baked`. +Compile order in `Partas.Build.fsproj` matters (F#): `System.CommandLine/Aliases.fs` → `System.CommandLine/Inputs.fs` → `Exceptions.fs` → `Output.fs` → `Terminal.fs` → `Annotations.fs` → `Environment.fs` → `Timing.fs` → `Producer.fs` → `Failures.fs` → `Conductors.fs` → `GitHubActions.fs` → `Conductors.Runners.fs` → `Process.fs` → `Operations.fs` → `Dependencies.fs` → `DependencyPlan.fs` → `ExecutionState.fs` → `Builders/StageSettings.fs` → `Builders/Stage.fs` → `Builders/Conditions.fs` → `Builders/PipelineSettings.fs` → `Builders/Pipeline.fs` → `Builders/Inputs.fs` → `Builders/Dependencies.fs` → `MachineOutput.fs` → `Explain.fs` → `Summary.fs` → `RunResult.fs` → `Builders/Command.fs`. The batteries-included layer is its own project, `src/Partas.Build.Baked`. -`Explain.fs` renders the resolved stage tree `--explain` prints, as text only, independent of the console and -any stage sink — a stage that silences or captures its output is still described in full. It compiles before +`Explain.fs` renders the resolved stage tree `--explain` prints, as text or JSON, independent of the console and +any stage sink — a stage that silences or captures its output is still described in full. `Explain.explain mode` +builds the model (`ExplainedPipeline`/`ExplainedStage`/`ExplainedStep`/`ExplainedCondition`); `renderWith` and +`toJson` render it. It compiles before `Builders/Command.fs`, whose `applyTo` registers the flag on every command and prints what the renderer returns. -It depends on nothing beyond the core model and the `Input.*` combinators. A command that runs pipelines gets +It depends on the core model, the `Input.*` combinators and `MachineOutput.fs` (for `--json` and the JSON +document writer). A command that runs pipelines gets `Explain.option` and reads it in its own action. A grouping command gets `Explain.groupingOption`, which carries the rendering — a list of the subcommands it dispatches to — on the option's own `Action`: such a command has no action of its own, and adding one would displace System.CommandLine's "Required command was not provided.". -Rendering evaluates every stage's `IsActive`, so a `whenBranch` starts `git` and a `whenStage` runs its condition -stage. `StageContext.Conditions` lets a skip name the condition that caused it: `addPredicateBecause` writes it -alongside `IsActive`. The structured conditions in `Builders/Conditions.fs` supply a reason; `when'` supplies -none, since a `bool` argument carries no reason to report. +Rendering never calls `IsActive`: it walks `StageContext.Conditions` — `addPredicateBecause` is the single writer +of both, so the list conjoins to `IsActive` — calling each condition at most once, stopping at the first that fails +or throws, and rendering a throw as its message (`condition failed: …`) instead of crashing. Under +`ExplainMode.Evaluated` (text `--explain`) a `whenBranch` starts `git` and a `whenStage` runs its condition stage, +once. Under `ExplainMode.Static` (`--explain --json`) a condition marked `Conditions.effectful` is reported +`unevaluated` with its description and left uncalled. The mark is the condition's runtime type, +`EffectfulCondition` (an `FSharpFunc` subclass), not a closure-keyed table: the optimiser copies a closure it +inlines, and an eta-expansion at a call site that coerces an argument (`whenBranches ["main"]` against a +`string seq` parameter) wraps it, so `whenBranches` takes `#seq` to keep the call a plain application. +`whenBranches`, `whenStageSucceeds` (`when' stage`, `whenStage`) are marked; `whenAll`/`whenAny`/`whenNot` mark +their result when any child is marked (`Conditions.combined`). A condition wrapped in an unmarked lambda is +evaluated. `StageContext.Conditions` also lets a skip name the condition that caused it. The structured +conditions in `Builders/Conditions.fs` supply a reason; `when' value` supplies none, and `when' value "reason"` +supplies the one written at the call site. + +`AiEnvironment.fs` precedes `MachineOutput.fs` and detects agent environment markers through a lazy result. +`MachineOutput.json` uses a fresh lazy detection in its default factory, so each invocation sees current +environment values. `PARTAS_BUILD_DISABLE_AI=1` disables automatic JSON, while explicit flags still win. +`--schema` and `--explain` remain explicit because they stop the command from executing normally. + +`MachineOutput.fs` holds the machine-readable flags and the command-tree schema. `applyTo` registers `--json` and +`--schema` on every command and `--report ` on a command that runs pipelines, each through `reserve`, which +skips a flag whose name or alias the command already declares (the consumer's option wins; System.CommandLine +would reject the duplicate). `--schema` carries its action on the option, like `Explain.groupingOption`, and +prints `MachineOutput.commandSchema` of the parsed command. Under `--json`, `--explain` prints `Explain.toJson` (static) +and a run prints `RunResult.toJson false` as one line after the run, in place of the timing table; `--report` +writes the indented form to a file. Both are written for every outcome the command action reaches, a failed +dependency validation included, but not for a System.CommandLine parse error, nor under `--explain` (a failed +validation there prints its message and exits `2`). An exception other than a pipeline failure or cancellation +writes the result as `Failed` and then propagates to System.CommandLine's handler (exit `1`). JSON is written with +`Utf8JsonWriter` by hand, not reflection serialization: every document carries `formatVersion` +(`MachineOutput.FormatVersion`). `System.Text.Json` is a package reference on `netstandard2.0` only. +`Input.sensitive` marks an option or argument as carrying a secret, recorded against the `Symbol` in a +`ConditionalWeakTable` in `Inputs.fs` (System.CommandLine has no property bag); `--schema` writes a marked +symbol's present default as `"***"` alongside `"sensitive": true`. `Baked.NuGet.apiKey` is marked, since its +default is read from `NUGET_API_KEY`. Text `--help` still prints the default. `Failures.fs` holds what a scope reports about itself, apart from what it prints. A `ScopeReport` carries three fields: `Outcome` (the scope's own `StageOutcome`), `Propagates` (whether that failure fails the scope @@ -123,18 +158,78 @@ line already carries. `Summary.render` sizes its three columns to the ambient co fit — the middle of a stage name, the end of an outcome — so the table is one row per stage at any width, and the `Depth` indent survives an 80-column CI log. +`RunResult.fs` holds what a command invocation answers, and the exit codes it ends with: `ExitCode.Success` +(0 — help, `--version` and `--explain` included), `Failure` (1, a stage failed), `UsageError` (2 — a parse +error, a missing subcommand, a failed `DependencyPlan.validate`) and `Cancelled` (130). `RunResult` carries the +code, its `RunOutcome`, and a `PipelineRun` per pipeline started — its `ScopeReports.stages` and ordered +`StageTimings`, snapshotted in `runReportingTimings`'s `finally` so a later run of the same pipeline value +leaves an earlier result intact. `Command.root { … }` builds a `RootCommandDefinition` (the `rootCommand` +operations, via the shared `RootCommandBuilderBase.Define`) without parsing or running; `Command.invoke args` +and `RootCommandDefinition.Invoke(args, ?output, ?error, ?cancellationToken)` parse and run it, and +`RootCommandBuilder.Run` is `Define` + `Invoke` + `.ExitCode`, so the exit-code mapping lives in `Invoke` alone: +a parse result whose `Action` is System.CommandLine's `ParseErrorAction` becomes 2 there, whatever the action +returned. The command action finds its invocation's state — the cancellation token and the `PipelineRun` +collector — in a `ConditionalWeakTable` keyed by the `ParseResult`; a parse result invoked directly through +System.CommandLine has none (the action makes a local, uncancellable one so `--json`/`--report` still see its +runs), and still gets 2 for a failed dependency validation (the +action returns it) but 1 for a parse error (System.CommandLine's own). The token reaches the engine through +`PipelineContext.runWith`, which links it into the pipeline's own timeout source. `output` replaces the +invocation configuration's `Output`/`Error`, which is where help, parse errors, `--explain`, the timing summary +and the dependency diagnostic go, and becomes the run writer (`Terminal.withOutput`, below) for everything the +run sends to the console. `Invoke` runs the parse-and-invoke on a `LongRunning` task and waits on it: a +`ThreadInterruptedException` on the waiting thread (SageFs's `cancel_eval` is `Thread.Interrupt`) cancels the +invocation's own linked `CancellationTokenSource` — reaching the registration-based process-tree kill through +the same token chain as `cancellationToken` — waits up to five seconds for the run to wind down, and rethrows. +`rootCommandOfScript` is `RootCommandBuilder Args.script`: the builder's primary constructor takes a +`unit -> string array` read once per `Run`, so argv is read when the command runs, never at module +initialisation; `rootCommand` is annotated `string array` to keep the array constructor's overload unambiguous. + +`Terminal.fs` (`Partas.Build.Internal.Terminal`) is the only route to the console. Spectre's static +`AnsiConsole` binds to the `Console.Out` current at its first use and keeps writing there after a host swaps +`Console.Out` (spike in `PLAN-Integration.md` §8 Q5), so the library never calls `AnsiConsole.*` directly: +`Terminal.ansi ()` answers `AnsiConsole.Console`, rebinding it to the current `Console.Out` when `Console.Out` +changed since the last call and nothing else replaced `AnsiConsole.Console` meanwhile (an explicit assignment, +as the tests' `capturingOut` makes, is kept). Each `Console.Out` writer keeps the console it was first seen +with, so restoring a writer restores its console. Writing through a host's `Console.Out` can deadlock: on Unix +the runtime locks `Console.Out` for every terminal write, and Expecto's `Console.Out` takes Expecto's lock before +writing to the terminal, so a pipeline thread and Expecto's logger take the two locks in opposite orders under a +pseudo-terminal. `tests/Partas.Build.Tests/Main.fs` therefore pins `AnsiConsole.Console` to the real stdout +before Expecto starts; a host with the same kind of writer passes `output` to `invoke` or pins the console the +same way. Under `Terminal.withOutput writer` — an `AsyncLocal`, so it flows +into the thread-pool work the run starts — `ansi ()` is a plain (no ANSI, no colour) console over `writer`, +`Terminal.out ()` (where a `Console`-sink step line goes) is `writer`, and `CmdRunner.outputPolicy` redirects a +`Console`-sink child, which would otherwise inherit the real stdout; `withOutput` installs the writer through +`TextWriter.Synchronized`, since several threads write to it. A console whose writer has no terminal +reports width `-1` and renders nothing, so `ansi`/`plain` give it `FallbackWidth` (80). `Terminal.ensureUtf8` +sets the console encodings at most once per process, skips a redirected stream, and swallows a failure. + +`PipelineContext.create` starts `EnvVars` empty: it holds the variables the pipeline sets. `runWith` layers +them over `PipelineContext.ambientEnvironment ()` read as the run starts, so a variable set after the pipeline +value was built is visible to its steps. `runWith` also refuses a second concurrent run of one pipeline value +(identity: the `ScopeReports` a value and its record copies share) with an `InvalidOperationException` before +touching anything; `PipelineContext.withRunState` gives a copy collections of its own that runs independently. +`Builders/Command.fs`'s `runReportingTimings` holds the guard itself (`PipelineContext.Running.enter`, then +`runEntered`) outside the `try` whose `finally` records and prints the run, so a refused invocation reports +nothing of the run holding the value. + `src/Partas.Build.Cmd` (`Program.fs`, `Execution.fs`) is the process layer, defining `Cmd` and compiling before `Partas.Build`. `src/Partas.Build.Baked` is the batteries-included layer over the library: ready-made `Input.*`/`Argument.*` definitions -for the options every build CLI ends up wanting (`--configuration`, `--nuget-key`, `--project`, `--ci`, a version -bump), the semver arithmetic in `Version`, and `IO.writeVersion`/`IO.bumpVersion` for editing a project file's -``. It is the only place in the library that writes to disk. +for the options every build CLI ends up wanting (`--configuration` and `Dotnet.configOrRelease`, `--nuget-key`, +`--project`, `--ci`, `--quick`, `--skip-tests`, `--watch`, a version bump), the semver arithmetic in `Version`, and +`IO.writeVersion`/`IO.bumpVersion` for editing a project file's ``. It is the only place in the library that +writes to disk, apart from the result file `--report` names. `Stages.fs` holds the prefab stages (`restore`, `clean`, `build`, `pack`, `expecto`, `nugetPush`, +`fantomas`, `npmInstall`): functions answering `InputSpec` that read Baked's own options, each with a +`…With` counterpart taking those options as `InputSpec`s. A skip carries a reason for `--explain`. `Clean.fs` is the +glob matcher behind `clean`, on `System.IO` alone; it never follows a symbolic link, so everything it deletes lies +under its root. The core model, once a single `Types.fs`, is split by responsibility and compiles in this order: 1. `Exceptions.fs` (`Partas.Build.ErrorHandling`) — pipeline exceptions, `FailureCause`, `StepOutcome`. -2. `Output.fs` (`Partas.Build.OutputHandling`) — `OutputCapture`, `StageOutput`, markup. +2. `Output.fs` (`Partas.Build.OutputHandling`) — `OutputCapture`, `StageOutput`, markup; then `Terminal.fs` + (`Partas.Build.Internal.Terminal`) — the console every write goes through, resolved at the moment of writing. 3. `Environment.fs`, `Timing.fs`, `Producer.fs` (`Partas.Build`) — `EnvArg`; `StageTimings`; `ProducerId`/`ProducerRef`/`ProducerValues` and the `ExecutionState` type. -4. `Conductors.fs` — `Partas.Build.Internal` first: `StageContext`, `PipelineContext`, `CommandSpec`. `Step` is `StepFn | Operation | StepOfStage`: a nested stage *is* one step of its parent, and stages nest arbitrarily. `StageParent` links a stage to a parent stage or the pipeline. The file ends in `Partas.Build` with the `StageContext` lookups that need `FsToolkit`/`HttpClient`. +4. `Conductors.fs` — `Partas.Build.Internal` first: `StageContext`, `PipelineContext`, `CommandSpec`. `Step` is `StepFn | Operation | StepOfStage`: a nested stage *is* one step of its parent, and stages nest arbitrarily. `StageParent` links a stage to a parent stage or the pipeline. The file ends in `Partas.Build` with the `ConsumerTypes` module — `[]` abbreviations of every model type a consumer names in a signature (`StageContext`, `PipelineContext`, `CommandSpec`, `StageParent`, `RuntimeContext`, `Step`, `StageCondition`, `StageIndex`, `StepIndex`, the `Build*` aliases; the `stepIndex` measure sits in the nested `[] Measures` module, since the Nacara reference routes pages case-insensitively and `stepIndex`/`StepIndex` would collide) — and the `StageContext` lookups that need `FsToolkit`/`HttpClient`, plus the consumer-facing re-exports `writeLine`/`getOutput`/`getVerbosity`/`getNamePath`. A consumer never needs `open Partas.Build.Internal`; `tests/Partas.Build.Tests/ConsumerSurfaceTests.fs` compiles without it and must keep doing so. 5. `Conductors.Runners.fs` (`Partas.Build.Internal.Runners`) — the execution engine (`StageContext.run`, `PipelineContext.run`). The `Build*` function aliases (`BuildStage`, `BuildStep`, `BuildStageIsActive`, …) no longer take an `ActionContext`: stages are pure, and the aliases match Fun.Build's originals except that Fun.Build declares them as delegates where this port uses plain function types. That difference has one mechanical consequence: **do not mark a CE entry member `inline` when it applies a `Build*` alias**, or Release builds fail with `FS1118` (Debug compiles fine). `Run` members are the usual offenders. `run (build: BuildStage, buildStep: StageContext -> BuildStep)` on `StageBuilder` stays non-generic: generalizing it ties it against the flexible-signature `run (step)` overload under `FS0041` at four existing call sites (`PLAN-Execution.md`'s T8 section). Being non-generic, it composes the alias itself, so that member must also stay non-`inline`. `FS1114` ("marked inline but was not bound in optimization environment") is the failure mode of forwarding one generic member to another sibling overload rather than building its step directly — moving the shared builder settings hit it in both Debug and Release, not only in Release like `FS1118`. @@ -156,7 +251,7 @@ a `voption` setting transfers only when the pipeline left it `ValueNone`; `PostS declared none; `AcceptableExitCodes` only while the pipeline is still on `set [0]`; the hooks and `Verify` only while they are still the `noStageHook`/`alwaysVerify` values `create` installed, so those are named module-level bindings rather than inline `ignore` — reference equality is the test; and `EnvVars` per key, -since a pipeline's map starts as the whole ambient environment. `NoPrefixForStep`/`NoStdRedirectForStep` are +a default key applying wherever the pipeline did not set that key itself. `NoPrefixForStep`/`NoStdRedirectForStep` are plain bools with no unset state, so a pipeline setting one to the value it already had is indistinguishable from not setting it at all — the single known gap. @@ -173,11 +268,18 @@ Overload resolution in these builders fails in ways that are invisible by inspec `Build/Program.fs` is the whole CLI — repository paths, options, stages and commands in one file. Repository paths come from `Partas.TypeProvider.BuildHelper` (`type Repo = BuildHelperProvider<...>`, with `Repo.FileSystem` and `Repo.VirtualFileSystem` for the real and virtual file systems), so a renamed project breaks compilation instead of failing mid-release. A new packable project goes in `Project.allProjects`, which is both what `bump` can version and what `pack` packs; `Repo.Project..Path` is a compile-time constant, while `PackageId`/`AssemblyName`/`Version` are MSBuild evaluations that shell out to `dotnet msbuild -getProperty`, so keep those off any path that runs before parsing. -Since Phase 7 the CLI is written against Partas.Build (`Build.fsproj` has a project reference to `src/Partas.Build`), so it doubles as the design's acceptance test. A step is a stage; a stage that needs a flag binds it in `inputs { let! quick = Options.quick ... return stage "..." { when' (not quick); run (cmd $"dotnet ...") } }`, and the command registers it by running the pipeline that contains it. The CLI has no per-command option lists and no process wrappers. Custom operations cannot sit under an `if`/`match`, so a stage that branches builds a `Cmd` first and runs it unconditionally, and a stage that exists only when an option was supplied is yielded through `whenSome` — `ProjectManagement.publish` yields its `nuget publish` stage that way, so the stage closes over the key rather than reaching for `key.Value` under a `when'`. +Since Phase 7 the CLI is written against Partas.Build (`Build.fsproj` has a project reference to `src/Partas.Build`), so it doubles as the design's acceptance test. A step is a stage; a stage that needs a flag binds it in `input { let! quick = Baked.Common.quick ... return stage "..." { when' (not quick) "--quick is set"; run (cmd $"dotnet ...") } }`, and the command registers it by running the pipeline that contains it. The CLI has no per-command option lists and no process wrappers. Custom operations cannot sit under an `if`/`match`, so a stage that branches builds a `Cmd` first and runs it unconditionally, and a stage that exists only when an option was supplied is yielded through `whenSome` — `Baked.Stages.nugetPush`, which `ProjectManagement.publishAll` uses, matches on the key the same way, so the stage closes over the key rather than reaching for `key.Value` under a `when'`. Restore, clean, build, pack, the Expecto suites and the push are Baked prefabs (`notes/PLAN-Integration.md` §3.1); the CLI keeps only what is its own — the compiler probe, the docs stages, `bump`'s project list. + +The four Expecto suites run `--sequenced`. Each drives real pipelines, and a pipeline writes to one process-wide console and holds a thread in `Async.RunSynchronously` for the length of every stage — run in parallel on a two-core runner, that yields a log whose lines belong to no test in particular, with enough blocked workers that the thread pool grows one thread at a time. `Baked.Stages.expecto` captures output under `--ci` by default; this repository overrides its suites to console output so assertions reach the CI log and uploaded artifact. Test child processes receive an empty `GITHUB_STEP_SUMMARY` so fixture commands keep their reports out of the real job summary. Library builds run sequentially because they share referenced assembly outputs. `Tests.execute` also runs `tests/Partas.Build.CompilerProbe` twice, once per configuration, regardless of `--configuration`. Release catches an `inline` member that applies a `Build*` alias (`FS1118`), which a Debug build compiles clean. -The four Expecto suites run `--sequenced`. Each drives real pipelines, and a pipeline writes to one process-wide console and holds a thread in `Async.RunSynchronously` for the length of every stage — run in parallel on a two-core runner, that yields a log whose lines belong to no test in particular, with enough blocked workers that the thread pool grows one thread at a time. `Tests.execute` captures the suites' output when `--ci` is set, so a green CI run says nothing and a red one lifts the whole failure into the annotation; locally it stays live. `Tests.execute` also runs `tests/Partas.Build.CompilerProbe` twice, once per configuration, regardless of `--configuration`. Release catches an `inline` member that applies a `Build*` alias (`FS1118`), which a Debug build compiles clean. +The `cmd`, `operations` and `CompilerTests` lists end in `|> testLabel "integration"`, so their tests' full names +start with an `integration` segment (`integration.cmd.…` in Expecto's output; a `--filter`/`--filter-test-list` on +them needs it, `--filter-test-case` does not). +SageFs live testing classifies a test whose full name contains `integration` as Integration and runs it only on +demand, instead of on every change under a five-second timeout; the rule is `CategoryDetection.categorize` in +SageFs's `SageFs.Core/Features/LiveTestingTypes.fs`, which for Expecto reads the name alone. -Fake survives only where it is better than a process call: `!!` and `Shell.cleanDirs` for the clean. Everything else is a `run` step. +There is no Fake dependency: the clean is `Baked.Stages.clean`, over `System.IO`. Everything else is a `run` step. ### Versioning @@ -198,6 +300,8 @@ a patch bump move it breaks anything not rebuilt in the same pass with `Could no - `.editorconfig` sets Stroustrup style, `max_line_length=150`, `fsharp_space_before_uppercase_invocation=true`. No fantomas tool is installed (`.config/dotnet-tools.json` declares no tools at all) and there is no `format`/`lint` command — match surrounding style manually. - Prefer `voption`/`ValueOption` and `[]` DUs in the library: a departure from the ported Fun.Build code. -- Public API goes in `[]` modules under `Partas.Build`; the model and engine stay in `Partas.Build.Internal`. -- Console output is Spectre.Console throughout, with GitHub Actions `::error title=...::` fallbacks when `GITHUB_ENV` is present (see `printError` in both context modules). +- Public API goes in `[]` modules under `Partas.Build`; the model and engine stay in `Partas.Build.Internal`. A model type that appears in a public signature gets an abbreviation in `ConsumerTypes` (`Conductors.fs`), and a lookup a step needs gets a re-export in the public `StageContext` module. +- Human-readable console output uses `Terminal.ansi ()`, which follows the invocation writer. Plain host consoles disable CI profile enrichment to preserve plain text. GitHub workflow commands use `Terminal.out ()` directly to preserve one physical line, enabled by case-insensitive `GITHUB_ACTIONS=true` detection (see `WorkflowCommands` and `printError` in both context modules). - The Nacara site (`docs/Site.fs`, `docs/docs.fsproj`) publishes every page under `docs/content/` and `docs/blog/`, plus the generated API reference, so internal working documents belong in `notes/` (as the `PLAN*.md` files do), not under `docs/`. +- The `.fsx` pages under `docs/content/` `#load` the library's sources in `` order: a file added to `Partas.Build.fsproj` or `Partas.Build.Baked.fsproj` goes into all four lists, or the pages stop type-checking. Check a page with `dotnet fsi .fsx` from its own directory. `docs/static/` is copied to the site root verbatim: `llms.txt` and `AGENTS-snippet.md` (the block a consumer pastes into its `AGENTS.md`, also packed into the `Partas.Build` package root). Nacara generates no `llms-full.txt`. +- XML docs carry `` blocks on the entry points an agent hovers (`run`/`runLine`/`runSensitive`, `input`, `whenSome`, `Cmd.argIf`/`argWhenSome`/`cmd`, `stage`/`pipeline`/`command`/`rootCommand`/`rootCommandOfScript`, `Command.root`/`invoke`, `RootCommandDefinition.Invoke`, `RunResult`, `MachineOutput.json`, `Baked.Stages`). They are written to compile: an edit to one is checked by pasting it into a scratch script against the built library. Escape `<` as `<` inside them; `GenerateDocumentationFile` is on and the build stays warning-free. diff --git a/Partas.Build.ExternalAnnotations/Library.fs b/Partas.Build.ExternalAnnotations/Library.fs index ca87b48..5c65715 100644 --- a/Partas.Build.ExternalAnnotations/Library.fs +++ b/Partas.Build.ExternalAnnotations/Library.fs @@ -58,30 +58,30 @@ module ExternalAnnotations = module Options = let strict = Input.option "--strict" - |> Input.desc "Fail when any member is skipped, rather than warning and continuing" + |> Input.description "Fail when any member is skipped, rather than warning and continuing" let annotationsTool = Input.optionMaybe "--annotations-tool" |> Input.arity Arity.ExactlyOne - |> Input.desc "Command that generates annotations during pack, e.g. 'dotnet partas-annotations'" + |> Input.description "Command that generates annotations during pack, e.g. 'dotnet partas-annotations'" |> Input.helpName "COMMAND" let force = Input.option "--force" - |> Input.desc "Overwrite an existing Directory.Build.targets" + |> Input.description "Overwrite an existing Directory.Build.targets" let assembly = Input.option "--assembly" |> Input.required |> Input.acceptLegalFilePathsOnly - |> Input.desc "Assembly to scan for annotations" + |> Input.description "Assembly to scan for annotations" |> Input.helpName "PATH" let output = Input.option "--output" |> Input.required |> Input.acceptLegalFilePathsOnly - |> Input.desc "Annotations file to write" + |> Input.description "Annotations file to write" |> Input.helpName "PATH" let attribute = @@ -89,28 +89,28 @@ module ExternalAnnotations = |> Input.def [||] |> Input.arity Arity.ZeroOrMore |> Input.allowMultipleArgumentsPerToken - |> Input.desc + |> Input.description "Collect only these attributes, by simple name; the default is every JetBrains.Annotations attribute" |> Input.helpName "NAME" let minMembers = Input.option "--min-members" |> Input.def 0 - |> Input.desc "Fail when any assembly's sidecar annotates fewer members than this" + |> Input.description "Fail when any assembly's sidecar annotates fewer members than this" |> Input.helpName "N" let package = Input.option "--package" |> Input.required |> Input.acceptLegalFilePathsOnly - |> Input.desc "The .nupkg to check" + |> Input.description "The .nupkg to check" |> Input.helpName "PATH" let directory = Input.option "--directory" |> Input.def "." |> Input.acceptLegalFilePathsOnly - |> Input.desc "Directory to write Directory.Build.targets into" + |> Input.description "Directory to write Directory.Build.targets into" |> Input.helpName "PATH" /// diff --git a/README.md b/README.md index ff1f8d3..1776065 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,8 @@ pipeline definition instead of by hand. Runs from a `.fsx` script or a build pro - **Documentation:** - **Every operation, one line each:** [`docs/content/Build/CAPABILITIES.md`](docs/content/Build/CAPABILITIES.md) ([rendered](https://shayanhabibi.github.io/Partas.Build/build/capabilities/)) -- **Agents:** start at +- **Agents:** start at ; a consumer repository can paste + [the agent snippet](https://shayanhabibi.github.io/Partas.Build/AGENTS-snippet.md) into its own `AGENTS.md` > The entire [`FSharp.SystemCommandLine`](https://github.com/jordanmarr/FSharp.SystemCommandLine) library is copied directly into this repo. All credit to the original author. > Much of the pipeline implementation is copied from [`Fun.Build`](https://github.com/slaveOfTime/Fun.Build). All credit to the original author. @@ -54,7 +55,7 @@ module Options = let quick = Input.option "--quick" |> Input.alias "-q" - |> Input.desc "Skip restores and cleaning" + |> Input.description "Skip restores and cleaning" let config = Input.option "--configuration" @@ -62,14 +63,14 @@ module Options = |> Input.def "Release" |> Input.helpName "Debug|Release" |> Input.acceptOnlyFromAmong [ "Debug"; "Release" ] - |> Input.desc "Build configuration" + |> Input.description "Build configuration" module Stages = let restore = input { let! quick = Options.quick return stage "restore" { - when' (not quick) + when' (not quick) "--quick is set" // the reason --explain prints when the stage is skipped run "dotnet restore" } } @@ -141,10 +142,29 @@ Options: | A stage that exists only when an option has a value | `whenSome`, which yields no stage for `None` rather than an inactive one | | A block of stages parameterised by an option someone else declares | Take an `InputSpec<'T>` parameter and `let!` it | | The root command to call itself something other than the script's filename | `name` on `rootCommand` | +| Restore, clean, build, pack, Expecto and NuGet push stages, ready made | `Partas.Build.Baked`'s `Stages` — each brings its own `--quick`/`--configuration`/`--skip-tests`/`--nuget-key` | +| What a command would run, without running it | ` --explain`; `--explain --json` for the tree as JSON, `--schema` for the options | +| A run's outcome for a script or an agent to read | `--json` (the last line of output) or `--report `; exit codes `0` success, `1` failure, `2` usage error, `130` cancelled | +| The build as a function in a warm F# session (SageFs) | `Command.root { }` and `Command.invoke`, which answer a `RunResult` — see [Hosting](https://shayanhabibi.github.io/Partas.Build/build/hosting/) | The right-hand column in full is [`docs/content/Build/CAPABILITIES.md`](docs/content/Build/CAPABILITIES.md) ([rendered](https://shayanhabibi.github.io/Partas.Build/build/capabilities/)). +`--json` defaults to true when the invocation environment identifies an AI coding harness. Detection follows +the environment rules in [is-ai-agent](https://github.com/sdairs/is-ai-agent#detection-order), including `AGENT`, +`AI_AGENT` and vendor markers; it does not probe the filesystem or inspect credentials. These inherited markers +are a convenience signal, not proof that an AI initiated the command. + +Use `--json false` for text on one invocation, or set `PARTAS_BUILD_DISABLE_AI=1` to disable automatic agent +defaults. The override is enabled by any nonblank value except `0`, `false`, `no` or `off` (trimmed and +case-insensitive). Explicit `--json` still works with the override. `--explain` and `--schema` remain explicit: +detection changes the output format, never whether the build runs. Child process arguments are unaffected. + +`AiEnvironment.detect ()` returns a `Lazy` that reads the environment only when `.Value` is requested. +Each JSON default creates a fresh detection, so a reusable command in a long-lived host sees environment +changes between invocations; a saved lazy result retains its first value. `AiEnvironment.detectWith` accepts +an environment reader for captured environments and tests. + ## Composition `command { stage; stage }` is the common form. Consecutive stages yielded into a command become one pipeline diff --git a/docs/content/Build/CAPABILITIES.md b/docs/content/Build/CAPABILITIES.md index a86d652..6944626 100644 --- a/docs/content/Build/CAPABILITIES.md +++ b/docs/content/Build/CAPABILITIES.md @@ -97,12 +97,13 @@ Available inside `stage`, and inside `whenStage`, which accepts everything `stag | Operation | What it does | |---|---| -| `run` | Adds a step. Takes a literal command line, a `Cmd`, or a function of the `StageContext` returning `unit`, `int`, `Result`, a `Cmd`, an `Async<_>` or a `Task<_>` of any of those, optionally wrapped in `option` | +| `run` | Adds a step. Takes a literal command line, a `Cmd`, or a function of the `StageContext` returning `unit`, `int`, `Result`, a `Cmd`, an `Async<_>` or a `Task<_>` of any of those, optionally wrapped in `option`. A function returning a `string` is obsolete: use `runLine` | +| `runLine` | Adds a step that runs the command line a function of the `StageContext` returns (`string`, `Async` or `Task`, optionally wrapped in `option`). The line is split on whitespace, honouring quotes | | `runSensitive` | Adds a step from an interpolated command line with every hole masked as `***` wherever the library prints it | | `runOperation` | Adds a step from an `Operation`, with an optional label for `--explain`. Runs under the stage's working directory, environment, acceptable exit codes and output routing | | `runHttpHealthCheck` | Adds a step that polls a URL until it answers or the stage is cancelled | | `echo` | Adds a step that prints a message through the stage's output sink | -| `when'` | Runs the stage only when a `bool` holds, or only when a given `StageContext` succeeds | +| `when'` | Runs the stage only when a `bool` holds, or only when a given `StageContext` succeeds. `when' (not quick) "--quick is set"` gives `--explain` the reason it prints against the skipped stage | | `whenEnvVar` | Runs the stage only when an environment variable is set, or set to a given value; also takes an `EnvArg` | | `whenBranch` / `whenBranches` | Runs the stage only on the named git branch. Reads `git branch --show-current` in the stage's working directory; a missing git evaluates false rather than throwing | | `whenWindows` / `whenLinux` / `whenOSX` | Runs the stage only on that platform. Pass `false` to invert | @@ -328,6 +329,60 @@ become one implicit pipeline carrying the command's name and description. `Comma pipeline written out, for when it needs pipeline-level settings. `pipeline "name" { }` is for several pipelines under one command, or a pipeline that needs its own name. +## Flags every command carries + +| Flag | What it does | +|---|---| +| `--explain` | Prints the resolved stage tree and runs nothing. A grouping command lists its subcommands | +| `--json` | With `--explain`, prints the tree as JSON, leaving `whenBranch` and `whenStage` conditions unevaluated. On a run, prints the run result — each stage's outcome, failures and timing — as one line of JSON after the run, in place of the timing table | +| `--report ` | Writes the run result as JSON to a file. Only on commands that run pipelines | +| `--schema` | Prints the command, its options (name, aliases, type, default, accepted values, description) and its subcommands as JSON, and runs nothing | + +`--json` defaults to true in an agent environment, detected lazily from `AGENT`, `AI_AGENT` or vendor markers +following [is-ai-agent's environment rules](https://github.com/sdairs/is-ai-agent#detection-order). +Use `--json false` to select text explicitly, or `PARTAS_BUILD_DISABLE_AI=1` to disable automatic defaults. +The override leaves explicit `--json` available. Detection is fresh per invocation in a long-lived host. +Neither `--explain` nor `--schema` is enabled automatically, and consumer-declared options keep their defaults. + +A command that declares one of these names itself keeps its own option. Every JSON document carries +`formatVersion`. Text `--explain` evaluates every condition, running a `whenStage` condition stage once; a +condition that throws is shown with its message. `Conditions.effectful description condition` marks a +condition of your own so the JSON form leaves it unevaluated too. + +## Exit codes + +| Code | `ExitCode` / `RunOutcome` | When | +|---|---|---| +| `0` | `Success` / `Succeeded` | Every pipeline succeeded, or the invocation printed help, a version, `--explain` or `--schema` | +| `1` | `Failure` / `Failed` | A stage failed, or the invocation raised an exception | +| `2` | `UsageError` / `UsageError` | The command line did not parse or validate, or the selected pipelines failed dependency validation, `--explain` included. No stage ran | +| `130` | `Cancelled` / `Cancelled` | The pipeline's own timeout expired, or the invocation's cancellation token fired | + +A stage's own `timeout` is a failure of that stage (`1`), not a cancellation. Ctrl+C terminates the process by +signal: it writes no run result, `--json` line or `--report` file, and the exit status is the shell's (`130` on +Unix by convention). + +## Invoking a command from code + +`rootCommand argv { }` parses and runs as it is constructed, and answers the exit code. `Command.root { }` takes +the same operations and builds a `RootCommandDefinition` without parsing or running anything, so a host binds it +once and invokes it any number of times. [Hosting a build in a long-lived session](hosting.md) walks through it. + +| Member | What it does | +|---|---| +| `Command.root { }` | Builds a `RootCommandDefinition`: every operation `rootCommand` takes, nothing run | +| `Command.invoke args root` | Parses `args` (without the script name), runs what they select, and answers a `RunResult` | +| `root.Invoke(args, ?output, ?error, ?cancellationToken)` | The same, with a `TextWriter` for everything the run prints (help, `--explain`, the pipeline's lines and console-sink child processes, as plain text), a writer for parse errors, and a token that cancels the run | +| `root.Command` | The underlying `System.CommandLine` `RootCommand` | + +`Invoke` runs on a thread of its own. `Thread.Interrupt` on the calling thread cancels the run as the token +would — killing the process tree of any running step — then raises `ThreadInterruptedException` from `Invoke`. + +`RunResult` is `{ ExitCode; Outcome: RunOutcome; Pipelines: PipelineRun list }`, where each `PipelineRun` is +`{ Name; Reports: ScopeReport list; Timings: StageTiming list }`, snapshots taken as the pipeline finished. Its +members flatten across pipelines: `Reports`, `Timings` (pre-order) and `Failures` (every `StepFailure`, tolerated +ones included). `RunResult.toJson indented result` is the document `--json` and `--report` write. + ## Condition builders `whenAll { }`, `whenAny { }` and `whenNot { }` take these. Each yields a single condition to a stage. An empty @@ -368,7 +423,7 @@ Shaping combinators, all `ActionInput<'T> -> ActionInput<'T>` and all pipeable: | Function | What it does | |---------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------| | `Input.alias` / `Input.aliases` | Adds alternative names. Options only | -| `Input.description`, `Input.desc` | The help text | +| `Input.description` (`Input.desc` is obsolete) | The help text | | `Input.helpName` | The value placeholder in help — `` | | `Input.defaultValue`, `Input.def` | The value used when the token is absent | | `Input.defaultValueFactory` | The same, computed from the `ArgumentResult` | @@ -376,6 +431,7 @@ Shaping combinators, all `ActionInput<'T> -> ActionInput<'T>` and all pipeable: | `Input.required` | Marks an option required | | `Input.recursive` | Applies the option to the command and, recursively, its subcommands | | `Input.hidden` | Keeps it out of help output | +| `Input.sensitive` | Marks the symbol secret: `--schema` reports it `"sensitive": true` and writes a present default as `"***"` | | `Input.allowMultipleArgumentsPerToken` | Lets one identifier token carry several values | | `Input.acceptOnlyFromAmong` | Restricts to a set of legal strings, ordinally | | `Input.addCompletion` / `Input.addCompletions` | Adds tab-completion suggestions without restricting what is accepted | @@ -414,7 +470,7 @@ let build (projects: InputSpec) = input { | `InputSpec.union` | Concatenates input lists, keeping the first occurrence of each | The `input { let! … and! … return … }` CE is the usual way to build one; `inputs` is the same builder under -a second name (`src/Partas.Build/Builders/Inputs.fs` binds both). It is applicative: bind every source +an obsolete second name (`src/Partas.Build/Builders/Inputs.fs` binds both). It is applicative: bind every source in a single `let!`/`and!` group. A sequential second `let!` is a compile error (`FS0708`): the input set must be readable before anything is parsed. An `input { }` nested inside another's `return` produces an `InputSpec>`, which nothing accepts — pass the *source* in as an `InputSpec` instead. @@ -470,6 +526,25 @@ equivalent. `BuildOption.map`, `.mapOpt` and `.mapArg` apply an `Input.*` combin | `Baked.Dotnet.config` | `configuration` (alias `-c`) as `string option`, over `release`/`r`/`debug`/`d` case-insensitively | | `Baked.SemVer.bump` | `bump` as `Bump option`, over `major\|minor\|patch\|alpha\|beta\|rc\|preview\|`, defaulting to `Patch` | | `Baked.Common.isCI` | `--ci`, defaulting to true when any of the usual CI environment variables is set | +| `Baked.Common.quick` | `--quick` (alias `-q`): skips restores, installations, cleaning and formatting | +| `Baked.Common.skipTests` | `--skip-tests` | +| `Baked.Common.watch` | `--watch` | +| `Baked.Dotnet.configOrRelease` | `InputSpec`: the `--configuration` value, `Release` when it is omitted | + +`Baked.Stages` holds ready-made stages, each an `InputSpec` that `stage`, `pipeline` and `command` +yield directly and whose options reach the command's `--help`. Each reads Baked's own options; its `…With` +counterpart takes each of those options as an `InputSpec<_>` instead. A skip reports its reason to `--explain`. + +| Function | What it does | +|---|---| +| `Stages.restore solution` | `dotnet tool restore`, then `dotnet restore`; skipped by `--quick` | +| `Stages.clean directories files` | Empties the directories and deletes the files the glob patterns select (`!` excludes), under the stage's working directory, following no links; skipped by `--quick` | +| `Stages.build projects` | One `dotnet build -c ` sub-stage per project, in parallel | +| `Stages.pack outDir projects` | One `dotnet pack --no-build --no-restore` sub-stage per project, in parallel | +| `Stages.expecto project arguments` | Runs a built Expecto suite through `dotnet run --no-build`; skipped by `--skip-tests`; under `--ci`, passes `--summary` and captures the output | +| `Stages.nugetPush packages` | `dotnet nuget push --skip-duplicate`, to nuget.org with `--nuget-key` (masked everywhere it prints) or to the source named `local` without one | +| `Stages.fantomas paths` | `dotnet fantomas` over the paths; skipped by `--quick` | +| `Stages.npmInstall directory` | `npm ci` under `--ci`, `npm install` otherwise; skipped by `--quick` | | Function | What it does | |---|---| @@ -482,7 +557,8 @@ equivalent. `BuildOption.map`, `.mapOpt` and `.mapArg` apply an `Input.*` combin ## Reference -- [Overview](build-overview.fsx) — the layers, and a first pipeline. +- [Overview](index.fsx) — the layers, and a first pipeline. - [Composing reusable blocks](composition.fsx) — blocks, nesting, and composition across files. +- [Hosting a build in a long-lived session](hosting.md) — `Command.invoke` from SageFs or any F# host. - [Stage CE run overloads](computation-expression-operations.fsx). - [API reference](https://shayanhabibi.github.io/Partas.Build/reference/) — full signatures and remarks. diff --git a/docs/content/Build/composition.fsx b/docs/content/Build/composition.fsx index d524bf6..7caaccd 100644 --- a/docs/content/Build/composition.fsx +++ b/docs/content/Build/composition.fsx @@ -23,6 +23,7 @@ order: 3 #load "../../../src/Partas.Build/System.CommandLine/Inputs.fs" #load "../../../src/Partas.Build/Exceptions.fs" #load "../../../src/Partas.Build/Output.fs" +#load "../../../src/Partas.Build/Terminal.fs" #load "../../../src/Partas.Build/Annotations.fs" #load "../../../src/Partas.Build/Environment.fs" #load "../../../src/Partas.Build/Timing.fs" @@ -42,18 +43,22 @@ order: 3 #load "../../../src/Partas.Build/Builders/PipelineSettings.fs" #load "../../../src/Partas.Build/Builders/Pipeline.fs" #load "../../../src/Partas.Build/Builders/Inputs.fs" +#load "../../../src/Partas.Build/AiEnvironment.fs" +#load "../../../src/Partas.Build/MachineOutput.fs" #load "../../../src/Partas.Build/Explain.fs" #load "../../../src/Partas.Build/Summary.fs" +#load "../../../src/Partas.Build/RunResult.fs" #load "../../../src/Partas.Build/Builders/Command.fs" #load "../../../src/Partas.Build.Baked/Program.fs" #load "../../../src/Partas.Build.Baked/Common.fs" #load "../../../src/Partas.Build.Baked/NuGet.fs" #load "../../../src/Partas.Build.Baked/Dotnet.fs" #load "../../../src/Partas.Build.Baked/SemVer.fs" +#load "../../../src/Partas.Build.Baked/Clean.fs" +#load "../../../src/Partas.Build.Baked/Stages.fs" open Partas.Build -open Partas.Build.Internal (** # Composing reusable blocks @@ -81,7 +86,7 @@ module Options = let quick = Input.option "--quick" |> Input.alias "-q" - |> Input.desc "Skip restores and cleaning" + |> Input.description "Skip restores and cleaning" let verbose = Input.option "--verbose" |> Input.alias "-v" @@ -262,7 +267,7 @@ items` does the same over a mapping. These are the two functions to reach for wh A wrapper can bind flags the wrapped blocks know nothing about, alongside the sequenced blocks: *) -let skipTests = Input.option "--skip-tests" |> Input.desc "Build the tests but do not run them" +let skipTests = Input.option "--skip-tests" |> Input.description "Build the tests but do not run them" let testGroup (blocks: InputSpec list) = input { let! stages = InputSpec.sequence blocks @@ -471,7 +476,7 @@ module Options = Input.option "--target" |> Input.mapFromAmong [ "node", "node"; "browser", "browser" ] |> Input.def "node" - |> Input.desc "Runtime the wire layer is generated for" + |> Input.description "Runtime the wire layer is generated for" module Stages = let generate layer = input { diff --git a/docs/content/Build/computation-expression-operations.fsx b/docs/content/Build/computation-expression-operations.fsx index 1f70598..373a073 100644 --- a/docs/content/Build/computation-expression-operations.fsx +++ b/docs/content/Build/computation-expression-operations.fsx @@ -22,6 +22,7 @@ order: 4 #load "../../../src/Partas.Build/System.CommandLine/Inputs.fs" #load "../../../src/Partas.Build/Exceptions.fs" #load "../../../src/Partas.Build/Output.fs" +#load "../../../src/Partas.Build/Terminal.fs" #load "../../../src/Partas.Build/Annotations.fs" #load "../../../src/Partas.Build/Environment.fs" #load "../../../src/Partas.Build/Timing.fs" @@ -41,20 +42,24 @@ order: 4 #load "../../../src/Partas.Build/Builders/PipelineSettings.fs" #load "../../../src/Partas.Build/Builders/Pipeline.fs" #load "../../../src/Partas.Build/Builders/Inputs.fs" +#load "../../../src/Partas.Build/AiEnvironment.fs" +#load "../../../src/Partas.Build/MachineOutput.fs" #load "../../../src/Partas.Build/Explain.fs" #load "../../../src/Partas.Build/Summary.fs" +#load "../../../src/Partas.Build/RunResult.fs" #load "../../../src/Partas.Build/Builders/Command.fs" #load "../../../src/Partas.Build.Baked/Program.fs" #load "../../../src/Partas.Build.Baked/Common.fs" #load "../../../src/Partas.Build.Baked/NuGet.fs" #load "../../../src/Partas.Build.Baked/Dotnet.fs" #load "../../../src/Partas.Build.Baked/SemVer.fs" +#load "../../../src/Partas.Build.Baked/Clean.fs" +#load "../../../src/Partas.Build.Baked/Stages.fs" open System.Threading open Partas.Build -open Partas.Build.Internal (** # Computation Expression Operations @@ -124,31 +129,36 @@ let _ = stage "stage" { run (fun _ -> task { do () }) (** +#### `runLine` + +A function returning a command line is `runLine`, not `run`. `run (fun ctx -> "...")` still compiles, marked +obsolete: a lambda written to return a message would otherwise start a process. The line is split on whitespace, +honouring quotes; build a `Cmd` with `cmd $"..."` to keep each interpolation hole as one argument. + ##### `commandFn: StageContext -> string` ##### `commandFn: StageContext -> Async` ##### `commandFn: StageContext -> Task` *) - run (fun _ -> "dotnet build") - run (fun _ -> async { return "dotnet build" }) - run (fun _ -> task { return "dotnet build" }) + runLine (fun _ -> "dotnet build") + runLine (fun _ -> async { return "dotnet build" }) + runLine (fun _ -> task { return "dotnet build" }) // With CancellationToken - run (fun _ -> "dotnet build") CancellationToken.None - run (fun _ -> async { return "dotnet build" }) CancellationToken.None - run (fun _ -> task { return "dotnet build" }) CancellationToken.None + runLine (fun _ -> "dotnet build") CancellationToken.None + runLine (fun _ -> async { return "dotnet build" }) CancellationToken.None + runLine (fun _ -> task { return "dotnet build" }) CancellationToken.None (** ##### `commandMaybeFn: StageContext -> string option` ##### `commandMaybeFn: StageContext -> Async` ##### `commandMaybeFn: StageContext -> Task` *) - // todo - overloads without CancellationToken should not require explicit typing - run (fun _ -> Some "dotnet build") - run (fun _ -> async { return Option.None }) - run (fun _ -> task { return Some "dotnet build" }) + runLine (fun _ -> Some "dotnet build") + runLine (fun _ -> async { return Option.None }) + runLine (fun _ -> task { return Some "dotnet build" }) // With CancellationToken - run (fun _ -> Some "dotnet build") CancellationToken.None - run (fun _ -> async { return Some "dotnet build" }) CancellationToken.None - run (fun _ -> task { return Some "dotnet build" }) CancellationToken.None + runLine (fun _ -> Some "dotnet build") CancellationToken.None + runLine (fun _ -> async { return Some "dotnet build" }) CancellationToken.None + runLine (fun _ -> task { return Some "dotnet build" }) CancellationToken.None (** diff --git a/docs/content/Build/hosting.md b/docs/content/Build/hosting.md new file mode 100644 index 0000000..040188c --- /dev/null +++ b/docs/content/Build/hosting.md @@ -0,0 +1,214 @@ +--- +title: Hosting a build in a long-lived session +category: Build +order: 5 +--- + +# Hosting a build in a long-lived session + +A build script run as `dotnet fsi build.fsx -- test` compiles the script, resolves its packages and starts a +runtime on every run, and every tool script it shells out to (`run "dotnet fsi tools/gen.fsx -- …"`) pays the same +again. A long-lived F# session — [SageFs](https://github.com/WillEhrendreich/SageFs), or any host holding an +`FsiEvaluationSession` — pays it once. The build then becomes a function the session calls, answering a +structured `RunResult` instead of an exit code and a screen of text. + +This page is written against SageFs, but nothing in it depends on SageFs beyond the location of its startup script. + +## The shape + +Three files, where a plain script build has one: + +| File | Holds | Side effects when loaded | +|---|---|---| +| `build.defs.fsx` | Options, stages, commands, and `let root = Command.root { … }` | None | +| `build.fsx` | `#load "build.defs.fsx"`, then one line that invokes `root` and exits | Runs the build, exits the process | +| `.SageFs/init.fsx` | `#load "build.defs.fsx"`, then a `build` function over `root` | None | + +The split exists because a session must never evaluate the last line of `build.fsx`. `rootCommand argv { … }` and +`rootCommandOfScript { … }` run the build *as they are constructed*, and `exit` terminates the process — in a +session, the session's own worker. `Command.root { … }` takes the same operations and builds the command without +parsing or running anything. + +### `build.defs.fsx` + +```fsharp +module BuildDefs + +#r "nuget: Partas.Build" +#r "nuget: Partas.Build.Baked" + +open Partas.Build +open Partas.Build.Baked + +let projects = [ "src/MyLib/MyLib.fsproj" ] + +let root = Command.root { + description "MyLib's build" + workingDir __SOURCE_DIRECTORY__ + command "build" { + description "Restores and builds" + Command.pipeline { + Stages.restore "MyLib.slnx" + Stages.build projects + } + } + command "test" { + description "Builds and runs the tests" + Command.pipeline { + Stages.restore "MyLib.slnx" + Stages.build projects + Stages.expecto "tests/MyLib.Tests/MyLib.Tests.fsproj" [ "--sequenced" ] + } + } +} +``` + +`workingDir __SOURCE_DIRECTORY__` makes every relative path resolve against the repository, whatever the +session's current directory is. The explicit `module BuildDefs` gives the file a name that reads well from the +other two; without it, the module is named after the file. + +### `build.fsx` + +```fsharp +#load "build.defs.fsx" + +open Partas.Build + +exit (Command.invoke (Args.script ()) BuildDefs.root).ExitCode +``` + +`dotnet fsi build.fsx -- test --quick` behaves exactly as a single-file build did: same commands, same `--help`, +same exit codes. + +### `.SageFs/init.fsx` + +```fsharp +#load "build.defs.fsx" + +open System +open Partas.Build + +let build (args: string list) = BuildDefs.root.Invoke(args, output = Console.Out) +``` + +SageFs evaluates the *text* of this file at the end of a session's warm-up, so `#load` paths resolve against the +session's working directory — the repository — rather than against `.SageFs/`. + +`output = Console.Out` is read on each call, which in SageFs is the writer capturing the current evaluation. +Given a writer, `Invoke` sends everything the run prints to it as plain text: help, `--explain`, the pipeline's +own lines, and the output of every child process whose stage writes to the console. Without it, a child process +inherits the worker's real standard output, and its lines never reach the evaluation's result. + +## Calling it + +From the session, or from an agent through SageFs's `send_fsharp_code`: + +```fsharp +build [ "test"; "--explain" ] // what would run, and what is skipped and why +let result = build [ "test"; "--quick" ] + +result.Outcome // Succeeded | Failed | UsageError | Cancelled +result.ExitCode // 0 | 1 | 2 | 130 + +[ for failure in result.Failures -> failure.Label, FailureCause.describe failure.Cause ] +[ for timing in result.Timings -> timing.Name, timing.Elapsed ] +``` + +`build [ "test"; "--json" ]` ends its output with the JSON form of the same result, for a caller that reads +text rather than F# values. + +## Mounting other scripts' commands + +A build that runs a tool script as a child process — + +```fsharp +stage "wire" { run "dotnet fsi tools/wire.fsx -- generate" } +``` + +— cold-starts `fsi` for it on every run, and loses the tool's result to an exit code. Split the tool the same way +and mount its command instead: + +```fsharp +// tools/wire.defs.fsx +module Wire + +#r "nuget: Partas.Build" +open Partas.Build + +let generate = command "wire" { + description "Regenerates the wire types" + stage "generate" { run (cmd $"dotnet run --project tools/WireGen") } +} +``` + +```fsharp +// build.defs.fsx +#load "tools/wire.defs.fsx" + +let root = Command.root { + // … + addCommand Wire.generate +} +``` + +`build [ "wire" ]` now runs in the session, ` --help` lists `wire` beside the build's own commands, and +` wire --help` lists the tool's options. +The tool keeps a thin `tools/wire.fsx` of its own for standalone use. A tool that exposes its stages rather than +a whole command composes further: yield them into a pipeline of the build's own. + +## Cancelling a run + +SageFs's `cancel_eval` interrupts the thread running the evaluation (`Thread.Interrupt`); it signals no +`CancellationToken`. `Invoke` runs the build on a thread of its own and waits for it, so the interrupt reaches the +waiting thread, which: + +1. cancels the run as a cancellation token would — the pipeline stops, and the whole process tree of every + running command is killed, grandchildren included; +2. waits up to five seconds for the run to wind down; +3. raises `ThreadInterruptedException`, which ends the evaluation. No `RunResult` is returned. + +A caller that owns a token passes it instead: `BuildDefs.root.Invoke(args, output = Console.Out, cancellationToken += token)`, and a cancelled run returns a `RunResult` with `Outcome = Cancelled`. + +What cancellation does not reach: + +- **A step written as a plain F# function** — `run (fun ctx -> …)` — runs on until it returns, unless it reads the + token itself (`Async.CancellationToken` inside an `async` step). An `async` step is stopped at its next + asynchronous wait. A blocking function keeps its thread after the evaluation has ended. +- **Work already done.** Cancellation stops what is running; it undoes nothing. + +## What stays shared across calls + +A session keeps every value it evaluates. For a build, that means: + +- **`root` is built once.** A pipeline written as `Command.pipeline { … }` or `pipeline "…" { … }` with no CLI + inputs is one value, reused by every call. Such a value runs one run at a time: a second call reaching it while + a first is still running — including a straggler step from an interrupted run — fails with exit code `1` and + "Pipeline '…' is already running". Stages yielded straight into a `command`, and pipelines built inside an + `input { }`, are built afresh for each call. +- **Option defaults are computed once**, when the module declaring them initialises. `Baked.Common.isCI` reads + the CI environment variables and `Baked.NuGet.apiKey` reads `NUGET_API_KEY` at that moment, and keep those + values for the life of the worker. Pass the flag explicitly, or restart the session + (`hard_reset_fsi_session`), after changing them. The environment a *stage* runs with is read afresh at the + start of every run. +- **Editing `build.defs.fsx`** and re-sending its `#load` defines a new `BuildDefs` module; the `build` function + bound earlier still calls the old `root`. Re-send the whole of `.SageFs/init.fsx` instead. + +The library keeps no other state between runs. It sets the console encodings at most once per process, and only +for a stream that is not redirected, and its console follows whichever writer `Console.Out` is when it writes. +Without a terminal — a SageFs worker has none — tables are laid out 80 columns wide. + +## Live testing + +SageFs discovers Expecto tests in a loaded project and runs them as code changes, with a five-second default +timeout per test. It classifies a test whose full name contains `integration` as an integration test, run only +on demand. A build's own test suites that start processes — as this repository's do — belong there: + +```fsharp +[] +let tests = + testList "cmd" [ + // … + ] + |> testLabel "integration" +``` diff --git a/docs/content/Build/index.fsx b/docs/content/Build/index.fsx index 9a47f1d..ced5343 100644 --- a/docs/content/Build/index.fsx +++ b/docs/content/Build/index.fsx @@ -21,6 +21,7 @@ order: 0 #load "../../../src/Partas.Build/System.CommandLine/Inputs.fs" #load "../../../src/Partas.Build/Exceptions.fs" #load "../../../src/Partas.Build/Output.fs" +#load "../../../src/Partas.Build/Terminal.fs" #load "../../../src/Partas.Build/Annotations.fs" #load "../../../src/Partas.Build/Environment.fs" #load "../../../src/Partas.Build/Timing.fs" @@ -40,17 +41,21 @@ order: 0 #load "../../../src/Partas.Build/Builders/PipelineSettings.fs" #load "../../../src/Partas.Build/Builders/Pipeline.fs" #load "../../../src/Partas.Build/Builders/Inputs.fs" +#load "../../../src/Partas.Build/AiEnvironment.fs" +#load "../../../src/Partas.Build/MachineOutput.fs" #load "../../../src/Partas.Build/Explain.fs" #load "../../../src/Partas.Build/Summary.fs" +#load "../../../src/Partas.Build/RunResult.fs" #load "../../../src/Partas.Build/Builders/Command.fs" #load "../../../src/Partas.Build.Baked/Program.fs" #load "../../../src/Partas.Build.Baked/Common.fs" #load "../../../src/Partas.Build.Baked/NuGet.fs" #load "../../../src/Partas.Build.Baked/Dotnet.fs" #load "../../../src/Partas.Build.Baked/SemVer.fs" +#load "../../../src/Partas.Build.Baked/Clean.fs" +#load "../../../src/Partas.Build.Baked/Stages.fs" open Partas.Build -open Partas.Build.Internal (** @@ -68,10 +73,11 @@ automatic validation and help. |---|---|---| | Step | `run`, `echo`, … | one action inside a stage | | Stage | `stage "name" { }` | `StageContext` | -| Inputs | `inputs { }` | `InputSpec<'T>` | +| Inputs | `input { }` | `InputSpec<'T>` | | Pipeline | `pipeline "name" { }` | `PipelineContext` or `InputSpec` | | Command | `command "name" { }` | `System.CommandLine.Command` | | Root | `rootCommand argv { }` | `int` exit code — **it runs immediately** | +| Hosted root | `Command.root { }` | `RootCommandDefinition` — runs on each `Command.invoke args` | ## A first pipeline *) @@ -191,6 +197,9 @@ let combined = `when'` also accepts a whole `StageContext`. It runs for real, side effects and console output included, and its success is the answer. +A `bool` carries no reason, so `--explain` prints a stage it turns off as `(skipped)`. Name the reason as a second +argument, `when' (not quick) "--quick is set"`, and `--explain` prints `(skipped: --quick is set)`. + ## Inputs A stage that needs a CLI flag binds it in an `input` CE. It is then lifted into any command that asks for it, @@ -201,7 +210,7 @@ module Options = let quick = Input.option "--quick" |> Input.alias "-q" - |> Input.desc "Skip restores and cleaning" + |> Input.description "Skip restores and cleaning" let config = Input.option "--configuration" @@ -278,6 +287,35 @@ let main argv = A command with no pipelines is a grouping node: it gets no action, so System.CommandLine reports the missing subcommand and prints help instead of succeeding silently. +The exit code tells a mistaken invocation from a broken build: `0` success (help, `--version` and `--explain` +included), `1` a stage failed, `2` the command line did not parse or validate — an unknown option, a missing +subcommand, a producer arrangement dependency validation rejects — and `130` the run was cancelled. The +`ExitCode` module names the four. + +### Invoking from a host + +`rootCommand` reads its arguments once and runs at construction. A long-lived host — an F# interactive +session, a test — builds the root once with `Command.root`, which takes every `rootCommand` operation and runs +nothing, then invokes it as often as it likes. `Command.invoke` answers a `RunResult`: the exit code, its +`RunOutcome`, and each pipeline run's `ScopeReport`s and `StageTiming`s. +*) + +let root = + Command.root { + description "My build" + addCommands [ buildCommand ] + } + +let buildQuick () = + let result = root |> Command.invoke [ "build"; "--quick"; "true" ] + printfn "%d failure(s)" result.Failures.Length + result.ExitCode + +(** +`root.Invoke(args, output = writer, cancellationToken = token)` sends help, `--explain`, the timing summary and +parse errors to `writer` instead of the console, and cancels the running pipeline — its processes with it — +when `token` fires. + ## Composition ### Stages nest diff --git a/docs/content/external-annotations/external-annotations-api.fsx b/docs/content/external-annotations/external-annotations-api.fsx index a5557cb..c4e4ba3 100644 --- a/docs/content/external-annotations/external-annotations-api.fsx +++ b/docs/content/external-annotations/external-annotations-api.fsx @@ -21,6 +21,7 @@ index: 2 #load "../../../src/Partas.Build/System.CommandLine/Inputs.fs" #load "../../../src/Partas.Build/Exceptions.fs" #load "../../../src/Partas.Build/Output.fs" +#load "../../../src/Partas.Build/Terminal.fs" #load "../../../src/Partas.Build/Annotations.fs" #load "../../../src/Partas.Build/Environment.fs" #load "../../../src/Partas.Build/Timing.fs" @@ -40,8 +41,11 @@ index: 2 #load "../../../src/Partas.Build/Builders/PipelineSettings.fs" #load "../../../src/Partas.Build/Builders/Pipeline.fs" #load "../../../src/Partas.Build/Builders/Inputs.fs" +#load "../../../src/Partas.Build/AiEnvironment.fs" +#load "../../../src/Partas.Build/MachineOutput.fs" #load "../../../src/Partas.Build/Explain.fs" #load "../../../src/Partas.Build/Summary.fs" +#load "../../../src/Partas.Build/RunResult.fs" #load "../../../src/Partas.Build/Builders/Command.fs" #load "../../../src/Partas.Build.Baked/Program.fs" #load "../../../src/Partas.Build.Baked/Common.fs" diff --git a/docs/content/index.fsx b/docs/content/index.fsx index bcb2fe3..1691c42 100644 --- a/docs/content/index.fsx +++ b/docs/content/index.fsx @@ -20,6 +20,7 @@ order: 0 #load "../../src/Partas.Build/System.CommandLine/Inputs.fs" #load "../../src/Partas.Build/Exceptions.fs" #load "../../src/Partas.Build/Output.fs" +#load "../../src/Partas.Build/Terminal.fs" #load "../../src/Partas.Build/Annotations.fs" #load "../../src/Partas.Build/Environment.fs" #load "../../src/Partas.Build/Timing.fs" @@ -39,17 +40,21 @@ order: 0 #load "../../src/Partas.Build/Builders/PipelineSettings.fs" #load "../../src/Partas.Build/Builders/Pipeline.fs" #load "../../src/Partas.Build/Builders/Inputs.fs" +#load "../../src/Partas.Build/AiEnvironment.fs" +#load "../../src/Partas.Build/MachineOutput.fs" #load "../../src/Partas.Build/Explain.fs" #load "../../src/Partas.Build/Summary.fs" +#load "../../src/Partas.Build/RunResult.fs" #load "../../src/Partas.Build/Builders/Command.fs" #load "../../src/Partas.Build.Baked/Program.fs" #load "../../src/Partas.Build.Baked/Common.fs" #load "../../src/Partas.Build.Baked/NuGet.fs" #load "../../src/Partas.Build.Baked/Dotnet.fs" #load "../../src/Partas.Build.Baked/SemVer.fs" +#load "../../src/Partas.Build.Baked/Clean.fs" +#load "../../src/Partas.Build.Baked/Stages.fs" open Partas.Build -open Partas.Build.Internal (** @@ -69,10 +74,11 @@ automatic validation and help. |---|---|---| | Step | `run`, `echo`, … | one action inside a stage | | Stage | `stage "name" { }` | `StageContext` | -| Inputs | `inputs { }` | `InputSpec<'T>` | +| Inputs | `input { }` | `InputSpec<'T>` | | Pipeline | `pipeline "name" { }` | `PipelineContext` or `InputSpec` | | Command | `command "name" { }` | `System.CommandLine.Command` | | Root | `rootCommand argv { }` | `int` exit code — **it runs immediately** | +| Hosted root | `Command.root { }` | `RootCommandDefinition` — runs on each `Command.invoke args` | ## A first pipeline *) @@ -192,6 +198,9 @@ let combined = `when'` also accepts a whole `StageContext`. It runs for real, side effects and console output included, and its success is the answer. +A `bool` carries no reason, so `--explain` prints a stage it turns off as `(skipped)`. Name the reason as a second +argument, `when' (not quick) "--quick is set"`, and `--explain` prints `(skipped: --quick is set)`. + ## Inputs A stage that needs a CLI flag binds it in an `input` CE. It is then lifted into any command that asks for it, @@ -202,7 +211,7 @@ module Options = let quick = Input.option "--quick" |> Input.alias "-q" - |> Input.desc "Skip restores and cleaning" + |> Input.description "Skip restores and cleaning" let config = Input.option "--configuration" @@ -279,6 +288,35 @@ let main argv = A command with no pipelines is a grouping node: it gets no action, so System.CommandLine reports the missing subcommand and prints help instead of succeeding silently. +The exit code tells a mistaken invocation from a broken build: `0` success (help, `--version` and `--explain` +included), `1` a stage failed, `2` the command line did not parse or validate — an unknown option, a missing +subcommand, a producer arrangement dependency validation rejects — and `130` the run was cancelled. The +`ExitCode` module names the four. + +### Invoking from a host + +`rootCommand` reads its arguments once and runs at construction. A long-lived host — an F# interactive +session, a test — builds the root once with `Command.root`, which takes every `rootCommand` operation and runs +nothing, then invokes it as often as it likes. `Command.invoke` answers a `RunResult`: the exit code, its +`RunOutcome`, and each pipeline run's `ScopeReport`s and `StageTiming`s. +*) + +let root = + Command.root { + description "My build" + addCommands [ buildCommand ] + } + +let buildQuick () = + let result = root |> Command.invoke [ "build"; "--quick"; "true" ] + printfn "%d failure(s)" result.Failures.Length + result.ExitCode + +(** +`root.Invoke(args, output = writer, cancellationToken = token)` sends help, `--explain`, the timing summary and +parse errors to `writer` instead of the console, and cancels the running pipeline — its processes with it — +when `token` fires. + ## Composition ### Stages nest diff --git a/docs/static/AGENTS-snippet.md b/docs/static/AGENTS-snippet.md new file mode 100644 index 0000000..ee13515 --- /dev/null +++ b/docs/static/AGENTS-snippet.md @@ -0,0 +1,67 @@ +# Partas.Build: instructions for coding agents + +Paste this block into a repository's `AGENTS.md`, `CLAUDE.md` or `.claude/rules/` when its build is written with +[Partas.Build](https://shayanhabibi.github.io/Partas.Build/). Replace `` with how the repository runs its +build: `dotnet fsi build.fsx --`, `dotnet run --project Build.fsproj --`, or the name of a tool. The same file ships +at the root of the `Partas.Build` package (`~/.nuget/packages/partas.build//AGENTS-snippet.md`). + +--- + +## Running the build + +The build is a command-line program. Ask it what it does instead of reading its source. + +1. ` --help` lists the commands. ` --help` lists the options that command accepts. + The help is generated from the stages the command runs, so it is complete and current: an option absent from + it does not exist for that command. +2. ` [options] --explain` prints the stages and steps the command would run with those options, + and which stages are skipped and why. It runs nothing. Run it before a command you have not run before, and + whenever an option's effect is unclear. +3. ` [options] --explain --json` prints the same tree as JSON. Conditions with side effects + (a `git` branch check, a condition stage) are reported as `"status": "unevaluated"` rather than run. +4. ` --schema` prints the command, its options (name, aliases, type, default, choices) and its + subcommands as JSON. A secret default prints as `"***"`. + +## Exit codes + +| Code | Meaning | What to do | +|---|---|---| +| `0` | Success; also `--help`, `--version`, `--explain`, `--schema` | Nothing | +| `1` | A stage failed, or the build raised an exception | Fix the code or the environment; read the run result for which step | +| `2` | Usage error: unknown option, missing command, failed validation, or an unsatisfiable stage dependency (also under `--explain`). No stage ran | Fix the command line; check ` --help` | +| `130` | Cancelled: the pipeline's own timeout expired, or the caller cancelled the run. A stage's timeout is a failure (`1`). Ctrl+C kills the process: the shell reports `130`, but no run result is written | Rerun, or raise the timeout | + +## Reading the result of a run + +Add `--json` to a run. The stages print as they otherwise would; the timing table is replaced by the run result, +which is the **last line** of output, one line of JSON: + +```json +{"formatVersion":1,"exitCode":1,"outcome":"failed","pipelines":[{"name":"test", + "reports":[{"name":"unit","address":"unit","path":[0],"outcome":"failed","error":"Exit code not acceptable.", + "propagates":true,"failures":[{"step":0,"label":"dotnet test","cause":{"kind":"reported", + "message":"Exit code not acceptable."}}],"nested":[]}], + "timings":[{"name":"unit","depth":0,"elapsedMs":90.7,"outcome":"failed","error":"Exit code not acceptable."}]}]} +``` + +- `outcome` is `succeeded`, `failed`, `usageError` or `cancelled`, matching the exit code. +- `pipelines[].reports` holds one report per stage, nested as the stages nest (`nested`). A report's `outcome` is + `succeeded`, `skipped` or `failed`; `propagates: false` marks a failure the build tolerated. +- `failures[].step` counts from zero and matches `index` in the `--explain --json` tree of the same stage; + `label` is the step's command line with secrets masked. `cause.kind` is `command`, `start` (the executable + could not be started), `raised` (an exception), `timedOut` or `reported`. +- `timings` lists every stage that finished, in order, with its `depth` and `elapsedMs`. + +`--report ` writes the same document, indented, to a file, with or without `--json`. Take the result from +there when the run's output is long or interleaved. + +Agent environment markers enable `--json` by default. Explicit `--json` makes the format predictable even +when a harness supplies no marker. Use `--json false` for text, or `PARTAS_BUILD_DISABLE_AI=1` to disable +automatic defaults. The override does not suppress explicit `--json`; `--explain` and `--schema` remain explicit. + +## Rules + +- Do not guess option names; read ` --help`. +- Do not edit the build to skip a failing stage. Read the failing step's `label` and `cause`, and fix the cause. +- A command listed under "Commands with no description" in `--explain` output has no help text; read its stages + with `--explain` before running it. diff --git a/docs/static/llms.txt b/docs/static/llms.txt index 414caf9..f0e8113 100644 --- a/docs/static/llms.txt +++ b/docs/static/llms.txt @@ -10,14 +10,20 @@ - [Capabilities](https://shayanhabibi.github.io/Partas.Build/build/capabilities/): every operation and Input combinator, one line each - [Index](https://shayanhabibi.github.io/Partas.Build/build/): the layers, and a first pipeline - [Composition](https://shayanhabibi.github.io/Partas.Build/build/composition/): nesting stages, and reusing commands across files +- [Hosting](https://shayanhabibi.github.io/Partas.Build/build/hosting/): running a build from a long-lived F# session (SageFs) through `Command.invoke` ## Ask the script rather than reading it Every command generates its own `--help` from the stages it runs, and the flags listed there are exactly the flags those stages bind. `