magus v0.3.0 is out. See what's new
¶ View markdown source · ✎ Suggest an edit
15 min read

Targets

A Target is the addressable unit of work in magus: what project (Path) and what operation (Name). Everything else (shards, spell filters, charms) is configuration layered on top, not part of the Target's identity.

A target is what you run (magus run lint); a spell is how a tool does it. See Spells vs Targets.

The struct

type Target struct {
    Path   string   // project path relative to workspace root
    Name   string   // the target name; the operation to run
    Charms []string // execution charms (modifiers); see docs/charms.md
    Files  []string // changed files; populated only by ExpandAffected
}

Path and Name are the two durable identities. Charms modify how the target runs (see charms). Files is metadata populated automatically by the VCS-affected engine; nil for explicitly constructed targets.

CLI grammar

[spell::]op-or-name[:charm,...]  [project ...]
   │       │           │              │
   │       │           │              └── projects, positional (omit = cwd/all)
   │       │           └───────────────── charms, comma-separated modifiers
   │       └───────────────────────────── a target name, or (with spell::) a spell op
   └───────────────────────────────────── spell, CLI-only op-direct qualifier

The project is a positional argument, not embedded in the target token. : introduces charms; spell::op invokes one spell's op directly (see spell-qualified targets).

Examples:

Command Project(s) Name Charms Spell
magus run build cwd / all build - -
magus run test api api test - -
magus run format:rw api api format rw -
magus run lint:rw,debug / all lint rw, debug -
magus run go::go-test all go-test (op) - go

Invalid forms:

String Reason
"" empty target rejected
lint: charm must not be empty
web/studio:test / not allowed in a target name (project is positional)

The canonical serialized form of a resolved Target (what Target.String() emits and what describe/logs show) is path:name (e.g. web/studio:test). That is the output form; the grammar above is how you type it.

Path resolution on the CLI

Path is stored relative to the workspace root, but the CLI accepts project names from anywhere in the tree. Arguments to magus run, magus list, and magus clean resolve as follows:

Input Resolved against Example (cwd = web/studio)
bare (api, web/studio) workspace root apiapi
dot-relative (./x, ../x) current working directory ../apiweb/api
. the project containing cwd .web/studio
empty or / all projects n/a

So ../foo behaves as a shell user expects: from web/studio, magus run build ../foo targets web/foo. Bare paths stay workspace-relative regardless of cwd. magus rejects two inputs:

  • Absolute paths (/etc, C:\foo): project paths must be repo-relative.
  • Paths that escape the workspace root (../../outside): magus never operates outside the workspace it discovered.

Resolution is implemented by internal/file/path.Resolve(input, anchor), where the anchor is the cwd expressed relative to the workspace root. The same helper backs WithDependsOn in a magusfile, so dependency paths and CLI arguments obey identical rules.

The workspace root is canonicalised (symlinks resolved) at discovery, and the sandbox enforces access against real, resolved paths on Linux via the kernel landlock LSM. A symlink inside the workspace pointing at /etc grants no access to /etc.

Two consequences:

  • Symlinked directories are not discovered as projects. Discovery does not follow symlinks; a symlinked directory is silently skipped.
  • Workspace-escaping symlinks are a hard error. magus doctor fails if it finds a symlink whose resolved target lands outside the workspace root. On platforms without landlock (macOS, Windows, kernels < 5.13) such a link is the only path by which a spell subprocess could reach outside the tree, so it is treated as a fail, not a warning.

The target name

A target name is typically one of the seven canonical operations (see below); custom names are allowed for work with no canonical home. The type is project.Target (a string alias).

Name Meaning
preflight pre-run checks (workspace health, missing tools)
build compile / produce artifacts
test run the test suite
lint static analysis, type-check
format format source files
clean remove local build artifacts
generate run code generators

There is also ci: an ordinary magusfile-defined target, not a hardcoded chain - you compose its stages yourself with magus\needs. Magus.RunCI treats it specially in exactly three ways: it strips the rw charm (ci always runs read-only), it is the anchor magus affected ci and magus affected --plan key off, and it must not silently no-op - a selected scope with no project declaring ci is a load error (see dependencies), not a quiet success.

Tool operations compose into these targets; they are not targets of their own. All static analysis - go-vet, golangci-lint, cargo-clippy, type-checks - belongs under lint (its definition is "static analysis, type-check"), not a bespoke vet, audit, or typecheck target. Reserve custom target names for genuinely distinct work with no canonical home (a deploy or release), not for fragmenting a canonical phase.

security is the exception that proves the rule, and magus's own workspace declares one. A scanner reads an advisory database that changes independently of your tree, so it needs skip_cache - and composing it into lint would spread that to every op lint runs, costing the whole phase its caching. A different cache contract is a real phase boundary, which is criterion 2 below rather than a naming preference. MGS1003 does not flag it.

Custom target names must use the target-name charset: letters, digits, -, _ (types.ValidateTargetName). :, @, and / are reserved for the grammar above.

When does a name earn canonical status?

The seven names above are a closed, deliberate set, not a starting point. A new name earns a place in it only if it passes all four:

  1. Universality - the phase must mean something in every toolchain magus adapts. A phase that only makes sense for one language fails this test: typecheck is universal-sounding but Go and Rust type-check as part of build, not as a separate phase, so it does not earn a canonical slot.
  2. Distinctness - it must be a genuine phase, not a subset of an existing one. vet, audit, and typecheck are all static analysis or formatting fragments of lint/format (see MGS1003), not phases of their own. A different CACHE CONTRACT counts as distinct: that is what separates security from the fragments it otherwise resembles.
  3. Pipeline membership - ci must need to order it against the other phases. A step nobody's ci ever sequences against build/test/lint has no claim on the canonical vocabulary.
  4. Tooling weight - a canonical name can carry engine semantics beyond "a bucket of ops": preflight/generate get drift-gating (see operations) precisely because they are canonical, not custom.

The v1 decision: this set is frozen at the seven above plus ci. deploy, release, and serve stay custom by design - they are real, common phases, but they are workspace-specific enough (which environment, which registry, which port) that forcing one shape on them would be more prescriptive than useful.

Name normalization (casing & delimiters)

Target names are matched case- and delimiter-insensitively. magus normalizes every name to canonical kebab-case (types.Normalize, a small hand-rolled kebab-caser matching samber/lo's KebabCase output without the dependency) on both sides: when a magusfile declares a target and when you reference one anywhere magus reads a target name. A target declared as go_build is reachable by any spelling that normalizes to go-build:

magus run go-build      # kebab
magus run go_build      # snake
magus run goBuild       # camel
magus run GoBuild       # pascal

This is normalize-both-sides, not an alias table: there is exactly one registered target (go-build), and the same normalizer runs over your input before lookup, wherever that input enters.

The normalizer lowercases, inserts - at camelCase and letter/digit boundaries, collapses each run of non-alphanumerics to a single -, and trims leading and trailing -. What that means in practice, including the cases people trip over:

You write magus resolves to Rule
go-build go-build already canonical
go_build go-build _ is a delimiter, not part of the name
goBuild go-build camelCase boundary
GoBuild go-build PascalCase boundary
HTTPServer http-server an acronym run breaks before its last letter
build2 build-2 letter/digit boundary
go--build go-build delimiter runs collapse to one

The last three are the surprising ones. HTTPServer does not become h-t-t-p-server, and build2 gains a - you did not type, so a target declared build2 is referenced as build-2 in anything that reports canonical names.

There is nothing proprietary here: the rule is ordinary kebab-case, and the Buzz standard library's strings\kebabCase computes exactly what magus resolves with. Run it and see:

import "std";
import "strings";

std\print(strings\kebabCase("go_build"));    // -> "go-build"
std\print(strings\kebabCase("goBuild"));     // -> "go-build"
std\print(strings\kebabCase("HTTPServer"));  // -> "http-server"
std\print(strings\kebabCase("build2"));      // -> "build-2"

That the two agree is not a coincidence you have to take on faith - a test holds them to identical output on every case in the table above (TestKebabCaseMatchesNormalize). Internally the resolver calls types.Normalize, which is also reachable from a magusfile as magus\normalize when you want to canonicalize a name yourself.

Buzz has testing built in, so the rule can be asserted rather than eyeballed - and a test block is exactly how the magusfiles and spells in this repo are tested. Save this and run magus buzz -t names.buzz:

import "std";
import "strings";

test "every spelling of a name reaches one canonical form" {
    std\assert(strings\kebabCase("go_build") == "go-build");
    std\assert(strings\kebabCase("goBuild") == "go-build");
    std\assert(strings\kebabCase("GoBuild") == "go-build");
    std\assert(strings\kebabCase("go-build") == "go-build");
}

test "the two that surprise people" {
    std\assert(strings\kebabCase("HTTPServer") == "http-server");
    std\assert(strings\kebabCase("build2") == "build-2");
}
ok    test "every spelling of a name reaches one canonical form"
ok    test "the two that surprise people"
---
2 passed, 0 failed, 0 skipped

Change one expected value and re-run to watch it fail - that is the whole testing workflow, and it is the same -t flag the spells in this repo are tested with.

Two things about that snippet are deliberate. It has no Run button, because the in-browser playground evaluates a script but does not execute test blocks - a runnable version would sit there reporting nothing while a wrong assertion looked like it passed. It also keeps to strings\kebabCase, which the standalone runner can resolve without a workspace - and which is the point rather than a concession: the rule really is just kebab-case.

Names are constrained to alphanumerics plus - and _. Everything else, : and @ especially, is reserved for reference grammar such as spell::target.

The contract

  • Declare in any convention, call in any convention. The declaration side (an export fun name in a magusfile) and the reference side (everywhere else) each run through the same normalizer, independently, before either is compared or stored.
  • Exactly one registered target. Normalization is not a lookup table with multiple aliases resolving to one entry; there is one canonical key, and every spelling that normalizes to it reaches the same target.
  • Collisions are a hard load error. Two declarations that collapse to the same canonical name (e.g. fooBar and foo_bar, both normalizing to foo-bar) make magus refuse the magusfile, naming the offending pair.
  • Convention drift is a doctor warning, not an error. Mixed conventions across a workspace still resolve correctly, but magus doctor warns when it sees more than one naming convention, since call sites across CI YAML, scripts, and docs can drift out of sync with whichever one you typed.

Where it applies

Surface Example
Magusfile declarations (export fun) export fun go_build(...) registers as go-build.
CLI magus run / magus affected arguments magus run goBuild reaches the target declared go_build.
magus\needs target handles ctx.needs(goBuild) resolves the target declared go_build (the handle's declared name is normalized).
The per-target policy map (magus\project's targets) A policy keyed "goBuild" applies to a target declared go_build, and vice versa.
Charm names target:NoCache and target:no-cache are the same charm.
Spell op keys A spell declaring an op go_build registers it as go-build.

One function does all of it: types.Normalize. There is no per-kind normalizer and no alias table.

Spell op keys are normalized too

Changed in v0.4.0

Op keys are now normalized when the spell is decoded, the same as every other name. Before, they were stored exactly as authored while every request arriving at the spell had already been normalized - so an op declared go_build was stored under go_build, looked up as go-build, and missed. The result was not an error: the dispatcher treated it as "this spell does not provide that target" and skipped it silently, logging only at debug level. The op was declared and reachable by nothing.

Every built-in spell already wrote kebab-case op keys, so nothing about the bundled spells changes. If you author a workspace-local spell with a camelCase or snake_case op, it now works instead of silently never running.

Where it deliberately does not apply

  • Spell names. A spell's own name is matched byte-for-byte. A spell named Go and one named go are two different spells, not one; the registry will hold both.
  • Lookups by literal key. Normalization canonicalizes what gets stored, not how a literal subscript is spelled. ts["tsc"] is an ordinary map-key lookup into the value import "magus/spell/ts" binds, so it must name the canonical (kebab) key. Likewise go::lint is still a graceful no-op - the go spell's linter op is golangci-lint, and that is a different word, not a different casing. See spell-qualified targets.
  • Project paths. Path is never normalized; api and Api are different (and, in practice, one of them just won't exist).

Worked example

Given a magusfile declaring:

export fun go_build(ctx: magus\Context, args: [str]) > void { go["go-build"](ctx); }

all four of these resolve to the one registered target go-build, and thus the one cache entry:

magus run go-build   # kebab: exact match
magus run go_build    # snake: normalizes to go-build
magus run goBuild     # camel: normalizes to go-build
magus run GoBuild     # pascal: normalizes to go-build

Terminology note: Target is magus's own term, distinct from Mage's vocabulary. In Mage, extensions:build is a single function name. In magus, extensions is a project Path and build is the target name: two orthogonal axes. Do not substitute Action, Operation, Task, Command, or Verb for Target in code, comments, or documentation.

CLI extension: spell-qualified targets

On the command line only, a double-colon prefix invokes one spell's op directly, bypassing your composed targets. The token after :: is a spell op (its CLI-command name), not a lifecycle target name:

magus run typescript::eslint api    # the eslint op of the typescript spell, in api/
magus run go::go-vet                # the go-vet op of the go spell, all projects
magus run go::golangci-lint         # the golangci-lint op of the go spell

This is an escape hatch for ad-hoc runs and introspection, not the everyday surface (compose ops into targets instead). Because it is op-direct, the name after :: is matched against the spell's op keys verbatim (no kebab/case normalization, unlike target names; see Naming operations):

  • go::golangci-lint runs that op.
  • go::lint is a graceful no-op: the go spell has no op named lint (its linter op is golangci-lint), so nothing runs.

The prefix is not stored in Target. The CLI strips it via parseTarget and passes it as a WithSpellFilter RunOption. The ci target does not support spell-qualified syntax.

What is not part of a Target's identity

These modify execution but are not durable identity. Charms parse into Target.Charms but propagate via context; the rest travel as RunOption values alongside the target list.

Input Purpose
:charm,... shared execution modifiers (see charms)
--shard / --n-shards CI matrix sharding (distributes projects across runners)
--dry-run prints what would run without executing
extra args after -- forwarded to the underlying tool via WithExtraArgs

Lifecycle: parse → expand → run

A target string goes through three stages before any tool is invoked:

"web/studio:test"  (or: name token + positional projects)
      │
      ▼
ParseTarget(s)          → Target{Name:"test", Charms:[...]}
      │                   types/target.go
      ▼
Workspace.ExpandPath(t) → []Target (one concrete entry per matched project)
      │                   magus/select.go
      │
      │   (alternative: ExpandCwd resolves for the project under cwd)
      │   (alternative: ExpandAffected uses VCS diff to select projects,
      │                 and populates Target.Files)
      ▼
Magus.Run(ctx, targets) → executes each target, grouped by Name; charms
                          ride along on the context (WithCharms)

Key invariant: targets passed to Run should be concrete (each Path resolves to exactly one project). ExpandPath, ExpandCwd, and ExpandAffected enforce this.

Glossary

Term Definition
Target An addressed unit of work: Path + Name + Charms + Files. The Target struct in types/target.go.
Path Project path relative to the workspace root. Empty or / means all projects.
Name The target name: the operation to run. One of: preflight, build, test, lint, format, clean, generate.
Charm A shared execution modifier (e.g. rw). Carried in context; see charms.
Files Repo-relative changed paths within a project. Populated by ExpandAffected; nil for explicit targets.
Spell A library of tool-native operations a target composes. Separate from Target; see spells.
ci An ordinary target you compose with magus\needs; Magus.RunCI only strips rw, anchors magus affected, and must-not-no-op.

See also

  • dependencies: magus\needs versus depends_on, and how a cross-project needs folds into the affected set and the cache key.
  • dependencies.md, pattern forms: the ctx.glob grammar - suffix shorthand, globs, and ! negation - with a worked example per form.
  • The guided tour, step 7: those pattern forms runnable in the browser.
  • operations: the formal Operation definition and the work hierarchy (Spell → Operation → Target).
  • spells: the operations a target composes, and Spells vs Targets.
  • charms: the execution modifiers attached after :.
  • engines: the Buzz engine a magusfile runs on.
targetsbuildprojectscligrammarspellscharmsmagusfile
Last updated (a103255f)
Earlier changes on this page (7)

Full history ↗ · Blame source ↗

Glossary

Workspace

The magus root directory that owns a set of projects and shared config; the unit magus operates over. See workspace.

Project

A directory magus recognizes as a unit of work (it has a magusfile); the unit of caching, scheduling, and dependency tracking. See workspace.

Magusfile

The magusfile.buzz that declares a project's targets (as export funs) and binds its spells. See targets.

Target

A named operation (build, test, ...) you invoke with magus run <target>; it may compose a spell's tool-native operations and depend on other targets. See targets.

Operation

A single tool-native command a target composes; the middle of the work hierarchy (Spell to Operation to Target). See operations.

Spell

A language/runtime adapter (e.g. go, md) that maps generic targets onto a toolchain's real commands. See spells.

Charm

An execution modifier attached with : (lint:rw) that changes how a target runs, not which one; the built-in rw flips a check-only target to mutate in place, and ci always strips it. See charms.

Ward

A coded diagnostic that inspects a resolved op and nudges or blocks an anti-pattern before it runs. See wards.

Buzz

The language magusfiles are written in (the .buzz engine). See engines.

Engine

The interpreter a magusfile runs on; magus embeds the Buzz engine. See engines.

Cache

The content-addressed store magus consults before running a target, so unchanged work is skipped. See cache.

Affected

The set of projects touched by a change; magus affected <target> runs a target only over them. See affected.

Sandbox

The restricted filesystem and environment a target runs in, so builds stay reproducible and side-effect-free. See sandbox.

CI

An ordinary magusfile-defined target you compose yourself with magus\needs - magus does not hardcode its stages. Magus.RunCI treats it specially only in that it strips the rw charm, it is the anchor magus affected ci keys off, and a selected scope with no project declaring it is a load error rather than a silent no-op. See targets.

Slot

One unit of the pool's capacity. A target acquires the slots it needs to run (most take one) and releases them when it finishes; the pool tracks capacity (total slots), running (acquired), and queued (blocked). See daemon.

Health

The at-a-glance daemon state derived from the pool: healthy when the pool is reporting, degraded when it reports an error, down when there is no pool. The dashboard color-codes each state. See daemon.

Conventions

This page uses none of the site's convention markers. The full set is on the conventions page.