xtask

A task runner whose tasks are data.

Write an xtask.yaml, and run xtask <task>. It resolves the dependency graph, runs each task once, in declared order, without a shell.

That is the whole of it, and the repository does not have to be a Dart one: the engine starts programs, so [pytest, -q] is as ordinary a task as [dart, test]. Later you may want to pin the engine's version in a pubspec.yaml instead of installing it, or to write a task in Dart rather than name a program — Using it from Dart is those two steps, and neither is needed to start.

The point is not convenience. It is that a repository stops keeping the same list twice. A Makefile names the commands, the CI workflow names them again, and a third copy usually lives in a contributing guide — and the copies drift the first time somebody is in a hurry. Here CI stops naming commands at all: a job runs one task, and what that task is made of lives in one file.

Start here

Install the engine once. Your repository needs no pubspec.yaml and no Dart in it at all — only the Dart SDK on the machine doing the installing:

dart install xtask

What lands on the PATH is a command called xtask.

Then write xtask.yaml at the repository root. This is a whole one:

version: 1

gates: [check]

tasks:
  lint:
    desc: check the style
    gate: [check]
    run: [ruff, check, .]

  test:
    desc: run the suite
    gate: [check]
    run: [pytest, -q]

Those two are a Python repository's tools; put in whatever your own repository already runs. Naming a program that is not installed is not a silent pass — the run stops and says which one:

error: task `lint`: `ruff` is not installed, or is not on PATH — nothing
runnable by that name in the 39 directories on PATH

And run it:

xtask check

That runs both, in the order they are written, and it is what a person types before calling work done — and also the whole of the CI job, the same command, because there is only one list and it is not in either of them. xtask --list prints the tasks with their descriptions.

Two lines in that file are doing the work. gates: [check] at the top says which groups this repository has; gate: [check] on a task says it is in one. xtask check then runs the group, in the order the file writes it — a gate set is not a task and needs none. Gate sets says why the whole tool exists for those two lines.

Using it from Dart

Two steps, and a Dart repository is the only kind that can take them. Neither is needed to start, and a repository that never takes either is using the tool exactly as intended.

Depending on it instead

A Dart repository can depend on xtask rather than installing it, and then the version is written down in pubspec.yaml instead of being whatever that machine happens to have. The command becomes:

dart run xtask:xtask <task>

Your own entry point

The second, and the only thing here that needs Dart code, is a verb: a function of your own for a job with real logic in it. An engine somebody else shipped cannot contain your function, so you hand it over from a file of yours:

dart run :xtask <task>

The colon is the whole difference between the last two, and it is easy to read past: what is written to the left of it is which package the executable comes from, and an empty left side means yours. Without a bin/xtask.dart of your own the short spelling fails with Could not find bin/xtask.dart in package <yours>, which is a truthful error and a baffling one if nobody said the file was optional.

dart install xtask puts a real xtask on the PATH, compiled, and for a repository whose tasks are all run: that is the pleasant way to work.

It stops working the moment a project registers a verb, and that is the design rather than a limitation. What gets installed is this package's own entry point, and it passes no verbs, because it cannot know yours: do: notify then meets "the engine ships no project verbs", correctly, since the notify the file means is a Dart function in your repository and not in the tool. That is why the entry point belongs to the project — a global install is the engine, and dart run :xtask is the engine plus what you wrote. The second thing is also pinned by your pubspec.yaml, where a globally installed tool is a version of its own that no repository can see.

dart run pays the JIT's start-up — around half a second, every invocation. It is nothing against a gate that spends seconds inside a test runner, and it is the entire cost of --gate-members, --why or --dry-run, which is where a shell loop or a file being written notices it. dart compile exe bin/xtask.dart removes it. The binary still reads xtask.yaml at run time, so tasks, gates and sets keep changing without recompiling; verbs are Dart, so a binary holds the ones it was built with and wants rebuilding after one changes. This repository keeps its own invocation as the aot task rather than a second copy in this file — xtask --dry-run aot prints it.

The rest of this README writes the short spelling, because this repository has a bin/xtask.dart. If you installed the engine and wrote no Dart, read every dart run :xtask below as plain xtask: the flags and the file are the same, and only the way the program is reached differs.

import 'dart:io';

import 'package:xtask/xtask.dart';

Future<void> main(List<String> args) async {
  // Assigned, not discarded. `runXtask` answers with the exit code below, and
  // `=> runXtask(args)` throws it away — the process then reports success for
  // every outcome, including the two that mean the file is wrong.
  exitCode = await runXtask(
    args,
    verbs: {
      'regen': regen,
    },
  );
}

A verb is ordinary Dart — testable, typed, debuggable:

Future<int> regen(VerbContext context) async {
  context.log('regenerating ${context.args.length} files');
  // context.member   which member of `each:` this run is for, or null
  // context.run(...)  a program, started the way a `run:` body is —
  //                   PATH, PATHEXT, the batch rule, the exit codes.
  //                   `workingDirectory:` is a path from the repository
  //                   root, and stays inside it; left out, it is the task's
  // context.args     `args:` with `$all` expanded, then anything
  //                  the command line passed after `--`
  // context.env      this machine's environment, with `env:` winning a clash
  // context.workingDirectory
  return 0;
}

The engine ships no project verbs — regen above is one repository's business, not the tool's. Its only built-in is remove.

The file name is the declaration: dart run :xtask resolves to bin/xtask.dart and nothing else, so nothing has to name it to make that work. (pubspec.yaml does carry an executables: entry, and it is there for dart install — which fails outright without one — not for dart run.)

The command

xtask <task>                 run a task and everything it needs
xtask <task> -- <args>       and pass those arguments to its body
xtask <task> --keep-going    report every failure, not just the first —
                             across tasks and across an `each:`
xtask <task> -j <n>          run n at once — which costs seeing their
                             output as it arrives. `-j auto` picks one
xtask --list                 every task, grouped under its gate set,
                             in the order `gates:` declares
xtask --list --gate <name>   only the tasks in that gate set
xtask --gate-members <name>  the tasks in that gate set, one per line
xtask --why <task>           what puts that task in a plan, and by which
                             `needs:` or `then:`
xtask --validate             parse and check the file; run nothing
xtask --check-ci             does the CI file still run the gate sets?
xtask --dry-run <task>       print the resolved plan; run nothing
xtask --emit-schema          print the JSON Schema for this file format
xtask --version              print which engine this is

xtask above is whichever spelling you arrived at — the installed command, dart run xtask:xtask, or dart run :xtask. The flags are the same in all three. The file is looked for from the current directory upwards, so the command works from a subdirectory and every path inside the file stays relative to the repository root.

Everything after -- reaches the body of the named task, after its args: and its expanded all:, and nothing else in the plan sees it — so xtask test -- -n "one test" narrows the tests without also handing -n to the formatter. A task with no body of its own is refused rather than swallowing them.

Seeing what will happen

Two questions the tool answers without running anything. Both are cheap, and both are the fastest way to find out that a file says something other than what you meant.

--dry-run shows what will actually happen, not what is written — sets expanded, $each substituted, and the executable resolved on this machine. The task names below are this repository's own, from xtask.yaml:

$ xtask --dry-run check
plan: format, analyze, test
format
  run  /opt/homebrew/bin/dart format --output=none --set-exit-if-changed .
  in   /Users/you/xtask
analyze
  run  /opt/homebrew/bin/dart analyze --fatal-infos
  in   /Users/you/xtask
...

The plan names the tasks, not the gate set: check is what you asked for, and what a run does is its members.

A do: remove block goes further and says what it would delete. The one verb this engine ships is the one that deletes recursively, and a plan showing only the pattern told you least about the operation you most need to check. In a project whose file has a clean task built on the remove verb:

$ xtask --dry-run clean
plan: clean
clean
  do   remove build coverage **/*.tmp
  in   /home/you/that-project
  del  build
  del  coverage
  del  src/a.tmp

Nothing is run to work that out — it is the reading the verb itself does — and a tree that is already clean says so rather than printing nothing.

Because it resolves them, --dry-run answers 3 when a program is not installed yet, naming the one it could not find. That is the same answer a real run would give, one step earlier — which is worth knowing before you read it as a bug on a machine where the tools are not set up.

--why answers the other direction — not "what does this run" but "why does this run at all". It names each entry point that reaches the task and spells the route edge by edge, saying which kind each edge is, because "it runs before this" and "it runs after this" are opposite answers:

$ xtask --why test
gate check
  gate check runs test
test
  nothing else names it: `test` is where a run starts

Two routes, because there are two: the gate set reaches it, and so does somebody typing its name. An edge says which kind it is — runs, needs, then — because "it runs before this" and "it runs after this" are the two answers a single word would blur.

Gate sets, and CI

A gate set is named after who runs it — one person's command, or one CI job's. Every set the file has is declared once, at the top:

gates: [check, release]

Names only — a gate set is not a task and has nothing to describe. Declaring them is what makes a misspelling a refusal: a gate that came into existence by being mentioned could not be misspelled, because the misspelling was a new gate. The order is the author's, and is the order --list groups by.

A task lists the sets it belongs to, and xtask check runs every task in check, in the order they appear in the file (cheap gates before slow ones) — except where a needs: or a then: between two of them says otherwise, since "it runs before this" is an order too, and the one the file states outright. A declared set nothing is in is refused, for the reason the empty-set rule exists: a gate that examined nothing reports the same green as one that passed.

There is no composite task, and nothing to keep in step with the declaration. A gate set and a task may not share a name — a person types one word — and needs: may not name a gate set, because an edge runs between tasks and a gate set is a list rather than a step.

That is the whole mechanism for removing the duplicate list. A CI job runs one invocation:

jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dart-lang/setup-dart@v1
      - run: dart run :xtask check

Run-once still holds, because a job is one invocation. Parallelism is preserved, because it comes from the jobs the CI system already schedules. And a failure is still legible: on a host that folds output — GitHub Actions today — each task is a collapsible section, and the failing one is annotated with the command and the directory, so the line that says a task failed is also the line that reproduces it.

--check-ci keeps that arrangement from rotting. It reads every file under .github/workflows — GitHub Actions is the only host it knows, and a repository without that directory is told so rather than passed — and compares what it finds with the gate sets in both directions: a run: step that is not one invocation of one gate set is refused, because that is exactly how the duplicate list grows back — somebody writes - run: dart analyze instead of adding a task. A gate set no job runs is reported rather than refused: gate sets are named after who runs them, and that is the jobs plus the people, which nothing in the file distinguishes. A step that asks xtask a question — --validate, --check-ci itself — is reported the same way: it names no command that could drift, so there is nothing to move into the file.

The rule is blanket, and the exception is written where the exception is: on the step's own run: line, after the command.

- run: dart run :xtask check
- run: npx playwright install --with-deps # xtask: not a gate — the browser driver, which no action installs

That is the one place the marker is read. A step is judged as written: the whole of its run: is one command line, and it either is one invocation of one gate set or it is not. Nothing here reads shell — no quotes, no &&, no cd — because every reading of shell a checker attempts is a way for a job that runs nothing to be counted as running something. So a run: | block of more than one line is a script, and a script is reported rather than read; a step with ${{ … }} in it is reported rather than guessed at; and a mention of xtask anywhere but in command position — echo run xtask check, timeout 600 ./xtask check — is a command like any other. Put the marker after the | to excuse a whole script, use working-directory: rather than cd, and put a timeout: on the task rather than around the invocation. A step with an if: is reported with its condition beside the gate it runs, since whether the condition holds is not something this file can say.

The reason is required. A marker with nothing after it is refused, because a marker with nothing after it is what this becomes when it is reached for to make a red gate green.

And it only excuses a step that would otherwise be reported as a command, which is the one thing it claims: that this step is not a gate. On a step that does reach xtask it is refused, whatever it says — one that runs a gate set, one that names a gate set under a mode, one that asks a question, one the command line itself turns away, one that names a gate set with a typo in it. Otherwise the marker is a way of making a job that runs nothing pass, which is the failure this whole mode exists to catch.

Every exemption is printed with its reason next to the jobs that passed, so a workflow that has quietly exempted its way to green says so in the same breath. And a workflow whose every run: step is exempted invokes xtask nowhere, which is refused as it was before: the marker cannot stand in for the invocation.

The rule stays blanket rather than growing a sense of which steps are "infrastructure" because no tool anywhere has one: the axis does not exist, and a checker inventing it would be classifying shell — the second grammar this mode is written not to keep.

It does not generate the workflow. Doing that would mean generating the checkout, the toolchain and the artifact upload too, which needs a template inside xtask.yaml — and templating is where an expression language starts.

The workflow file still owns what must exist before anything runs: the checkout, the toolchain, a browser driver. xtask owns what runs. There is no key that installs something; the one thing there is, is a precondition check:

web-e2e:
  desc: browser e2e for the web binding
  gate: [ci-web]
  env-required: [CHROMEDRIVER]
  in: packages/lake
  run: [dart, test, test/web/web_e2e_test.dart]

which turns "a browser test failed somewhere inside" into "task web-e2e requires CHROMEDRIVER, which is not set".

interruptible: true is the third of these, and it gives back what -j otherwise costs. A run does not reach into what is already running, because a build killed half-way leaves whatever it was doing in whatever state that half is. That is right for a build and wrong for a check: dart format --output=none, dart analyze and dart test write nothing a half-run would leave behind, and the engine cannot tell the two apart while the person who wrote the task can. Sequentially a format failure at 0.4s means the rest never run; in parallel they run to the end anyway and the machine spends the whole budget to learn what it knew in a tenth of a second. With the key, the fast answer arrives at the fast answer's price. --keep-going stops nothing at all, which is the whole of what that flag says.

-j says how many; the file says whether. serial: true is one task whose members must not overlap — six packages sharing one ~/.pub-cache, or one git index, where git add fails outright rather than waiting. exclusive: [chromedriver] is the same fact between tasks: two suites the graph calls independent may still drive the one browser on the machine — and because the token is held by the task, a task that holds one runs its own each: members one at a time as well. Naming a browser and then driving it from four members at once would be the guarantee said and not kept. Both can only ever make a run slower, never change its result, which is the right property for something every machine reads — and getting them wrong makes a run flaky, which is a different kind of wrong from making it slow. A number would be one machine's width written into a file the rest of them share, so there is no key for it.

Reading a run

What a run tells you while it happens and when it ends — and what changes about that when you ask it to go faster.

A run has one duration, which answers nothing on its own, and a CI job that is one invocation has only that one. So the run prints what each task took at the end — after the last section, because a line inside a fold is invisible in exactly the state somebody is in when they want a number:

format    0.4s
analyze   2.3s
test     11.7s
total    14.4s

--keep-going is for the local loop. A gate that stops at the first failure makes you fix, rerun, fix, rerun — the same argument --validate is built on, which is why it collects every problem rather than throwing at the first. With the flag, independent tasks still run and the run ends with a summary:

failed   lint (exit 1)
failed   unit (exit 1)
skipped  check — needs `lint`, which did not pass

A task whose requirement failed does not run: its own failure would be a consequence of the first one. It is named as skipped rather than dropped, because a task that silently did not happen reads exactly like one that passed.

It is off by default, because a pipeline wants the earliest possible red rather than a broken run read to the end.

-j <n> runs tasks that do not depend on each other at once — and the members of one each: at once. -j auto is this machine's processors, capped at 8, because one unit here is a whole dart test or dart analyze rather than a thread; -j N is the number you write. It is not the default, and the reason is a real cost rather than caution: normally a task's output passes through as it arrives and each task is a section that folds, and two tasks writing to one terminal at once break both — the transcript belongs to neither. So a parallel run collects each task's output and prints it whole when that task ends. You get the answer sooner and you watch it happen less — and it says so on the first line, because a run that goes quiet for eight seconds without explaining itself is indistinguishable from one that has hung.

The summary then says both numbers, because they answer different questions:

lint   1.0s
unit   1.0s
types  1.0s
total  3.1s spent, 1.1s taken

Declaration order still decides which of the ready tasks starts first — cheap gates before slow ones — but nothing makes them finish in that order. A failure stops what has not started; it does not reach into what is running, because killing a task would leave whatever it was half-way through in whatever state that half is. Whichever way it ran, the summary then names what did not run:

failed   format (exit 1)
skipped  analyze — the run stopped at an earlier failure
skipped  check — needs `format`, which did not pass

Exit codes

An exit code is not a success flag; it is the shortest possible bug report.

code meaning
0 everything asked for ran and passed
1 a task ran and failed
2 the request was refused — a bad document, an unknown key, a cycle, a dangling reference, a set that expands to nothing; and a command line the parser turns down, or a --check-ci that found something
3 a task's executable was not found
4 a task's body succeeded and one of its then: continuations failed

A 4 stops the run exactly as a 1 does: what has not started does not start, what is running is left alone, and the summary names the rest. The code says which of the three endings happened, not how much of the plan was abandoned — those are different questions and --keep-going is the one that answers the second.

With --keep-going and more than one failure, the code is the first failure's. A code is a report about one failure, and a run with three cannot honestly claim to be about all of them; the summary is where the others are.

3 is separate because "Dart is not installed on this machine" and "the code is broken" are repaired by different people, and one code sends both to the same one.

A verb's exit code is what the run answers with — it is your Dart, written against this table, whose constants the package exports as ExitCode so a verb can name the reason rather than the digit; and the built-in remove answers 2 for a path outside the repository because that means the file is wrong. A program started by run: has never heard of this table, so its code goes in the message and the run answers 1.

4 exists because a publish followed by a verification has three endings, not two: nothing was published, everything passed, or the upload happened and the check after it is red. Collapsing the third into 1 tells a pipeline the publish failed, which is false and unrecoverable in the wrong direction — the registry will not accept that version again.

The keys

key meaning
desc required, one line, what --list prints
run an external program as argv — the program, then its arguments, each its own entry. Never a command line; nothing splits a string and no shell sees it
do a verb: remove, or one this project registered
args extra arguments appended to the body
all a set whose members replace the $all marker, in one invocation — a whole argument, written once
each a set whose members the body runs once per, with $each standing for the member
in where the body runs, relative to the root — may end with $each
env environment for this task only
env-required variables that must already be set, checked before the body runs
needs direct requirements, run before this task, once per invocation
then continuations, run after this task's body
gate the gate sets this task belongs to
serial this task's each: members must not overlap, whatever -j says
exclusive tokens this task holds alone while it runs — which makes its own each: members serial too
interruptible a failure elsewhere may stop this task where it stands
timeout seconds a run: body may take before it is killed — per member under each:

timeout: is asked of the process, not waited out by the engine: the body is sent SIGTERM, given a moment to write what it has, and then SIGKILL. What it does not do is reach the process's own children — Windows has job objects, POSIX has process groups, and neither is what Dart exposes — so a task that spawns a server and hangs may leave the server behind. A do: cannot carry a timeout: at all: a verb is a Dart function, nothing outside it can stop one, and a limit that passed while the verb kept writing to disk would be worse than none. That is refused when the file is read, not discovered at runtime.

The example at the top uses three keys because three is what that repository needs. Here is one using the rest — a release, which is where needs:, then: and a verb all earn their keep at once:

version: 1

sets:
  packages:
    include: [packages/*]

tasks:
  build:
    desc: build every package
    each: packages
    in: $each
    run: [dart, run, build_runner, build, --delete-conflicting-outputs]

  publish:
    desc: publish, and announce it only if that worked
    needs: [build]
    then: [announce]
    env-required: [PUB_TOKEN]
    timeout: 600
    run: [dart, pub, publish, --force]

  announce:
    desc: post the release note
    do: notify
    all: packages
    args: [$all]

each: runs the body once per member — in the order the set gives them, and one at a time unless -j says otherwise. $each is that member, and it may stand as a whole argument or end one — packages/$each, --flavor=$each — with nothing after it. That line is the same line twice: a prefix lets a set hold the part a path cannot be derived from, the bare name, so one task can have both in: packages/$each and --name $each; and no suffix is what keeps build/$each.dart out, because deriving a path from a value is a computation, a computation wants a modifier, and a modifier wants a language. Deriving belongs in a verb. needs: is "before, and once however many tasks ask for it"; then: is "after, and only if the body worked" — which is the whole reason exit code 4 exists, because publish succeeding and announce failing is a third ending and not a failure to publish. env-required: is checked before that task's body runs — not at the start of the run — so a missing token is a sentence rather than a broken upload. do: names a verb the project wrote in Dart and handed to runXtask, and all: hands it the expanded set, wherever $all is written.

A set is a list of paths, a glob with exclusions, or values: for members that are not paths at all. The first two are expanded by the engine rather than by a shell, in a deterministic order:

sets:
  packages: [packages/lake, packages/lake_cli]

  sources:
    include: ['{templates,packages}/**/*.lake']
    exclude: ['**/test_data/**']

  flavours:
    values: [dev, staging, prod]

**/ means none or more directories, as bash and git read it — so packages/**/*.lake finds packages/x.lake too. That is two readings of one pattern: it with the **/ kept, and it with the **/ dropped. A pattern carrying more than a handful of them is refused rather than matched, because every reading becomes a glob compared against every file the walk touches. The readings are counted rather than estimated as 2ⁿ — they collapse, and a/**/**/b has three and not four — so the limit turns away what is actually expensive and nothing else.

values: is a declaration, not decoration. Every other kind of set holds paths, and the engine treats them as paths — it refuses one that reaches outside the repository, and it can say a glob matched nothing. Neither question means anything about dev, and asking the first refused a:b for looking like a Windows drive. It is also what lets a set hold the bare name a path cannot be derived from, with in: packages/$each composing the path around it.

A member a glob found that begins with - is refused where it would reach a program's arguments: a file called -n.dart is a path to you and an option to almost everything else. Write -- before the marker, where a command line says its operands begin — the engine will not add it for you, because that would change the argv the task wrote. A member you wrote out yourself is not refused: you can see it, and a values: set holding -v is somebody passing a flag on purpose.

A set is read when the task that names it is about to run, and at no other time. --validate and --dry-run happen before that, so a set the run itself produces says so:

sets:
  generated:
    include: ['build/**/*.dart']
    produced-by: codegen

That buys exactly one thing: the emptiness of this set is not judged before codegen has run. --validate passes over it and --dry-run prints cannot be resolved yet with the reason under it, instead of calling a working file broken. Everything else about the set is still checked by both — a pattern that leaves the repository, a pattern that is not a pattern — and a run still refuses it empty, whatever was hoped for it.

The producer is named rather than flagged, because a name is something the file can be checked against: every task that reads the set has to reach codegen through needs:, or --validate says so. In the file's order the producer may well come first anyway; under -j nothing but the edge says so, and a reader that does not need its producer reads a set that is not there yet.

A set that expands to nothing is an error: a task given no files checked nothing, and a gate that examined nothing is worse than no gate.

Editor support

xtask --emit-schema prints a JSON Schema for the file format. Generate it into your repository and point at it with a relative path, so a fresh clone needs neither the network nor a per-person editor setting:

xtask --emit-schema > xtask.schema.json
# yaml-language-server: $schema=./xtask.schema.json
version: 1

The schema knows the shape of the file: it completes a task's keys, and underlines dsec: or a gate: written as a string, while you type. Everything that needs the graph or the filesystem — a cycle, a needs: pointing at nothing, an undeclared or empty gate set, an orphan gate, a glob matching nothing, an unregistered verb, an in: that reaches outside the repository — is what --validate answers. A schema catches a mistyped key; --validate catches a mistyped name.

The schema describes one version of the engine, which is why it is generated into your repository rather than fetched from a URL.

Three rules

Not style. Each prevents a failure that has already happened somewhere.

R1 — no control flow in the file. No conditionals, no branching, no shell, no capturing one command's output to feed another. A task that needs a condition becomes a verb. The moment the file can ask "did that work?", it is a programming language with no debugger and no types.

R2 — no inheritance. A task is read completely from its own keys. This costs repetition and buys the property that what is written is what happens — and it keeps the engine from growing precedence order and "where did this value come from" tooling.

R3 — a built-in primitive is total and argument-driven. It takes paths or values and performs an effect; it never branches on the result of anything. remove: [paths] qualifies, test -f X && Y does not. This is what stops the primitive list from becoming a portable shell — the failure the npm ecosystem took, one package per utility (rimraf, mkdirp, cross-env, shx), all of them existing only because package.json scripts are shell.

What it deliberately is not

  • Not a build system. No up-to-date checks, no artifact graph, no caching. An expensive task solves that inside its own verb.
  • Not a package manager and not a monorepo tool. melos runs shell across packages; xtask runs a graph without one. They do not overlap.
  • Not parallel by default. Tasks run in order, one at a time, and parallelism belongs to the CI system, which already has it. -j is there for the local loop and costs watching the output arrive.
  • No plugins, no dynamic loading, no expression language. Verbs are code the project links; everything else is data.
  • No templating or interpolation beyond $each. The moment a value can be computed in the file, R1 is gone.

Windows

run: is argv, and the engine resolves the program itself — walking PATH, honouring PATHEXT, and knowing that CreateProcess cannot start a .bat or a .cmd however it is asked. A shim goes through the shell because there is no other way; an argument that the shell would reinterpret is refused with the character named, rather than passed through to mean something else.

Windows has no argv. CreateProcess takes one string, and the runtime at the other end splits it again — so an array is a promise somebody has to keep by quoting. Dart's Process does that, by the rules CommandLineToArgvW reads back, and a task written as a list arrives as that list: a path with a space in it stays one argument and is not two. Where quoting is not enough the engine refuses rather than hopes, and that is the batch shim above — cmd.exe parses the line a second time, after the quoting, by rules of its own.

License

MIT — see LICENSE.

Libraries

xtask
xtask — a task runner whose tasks are data.