magus v0.4.2 is out. See what's new
¶ View markdown source · ✎ Suggest an edit
17 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

ci is the only target magus requires. Every other name below is a CONVENTION - a shared vocabulary for phases that recur across toolchains, not a set you must declare. A project that defines only ci is complete and correct. The type is project.Target (a string alias).

Name Meaning
preflight pre-run checks (workspace health, missing tools)
build produce artifacts that are NOT committed
test run the test suite
lint static analysis, type-check
format format source files
clean remove local build artifacts
generate produce files that ARE committed

generate or build?

Both produce files, so the table above splits them on the only difference that changes what CI can do: is the output committed?

generate's output is in the repository, so CI can re-run the generator and fail when the bytes move. That drift gate is the whole point of the name - it is what catches a page nobody regenerated after editing its source. build's output is ignored or thrown away, so no comparison is possible; it is verified by producing without error.

The test is the artifact, not the verb. "It runs a generator" is not the question - a code generator writing an ignored dist/ belongs in build, and a renderer writing a committed SVG belongs in generate.

Two failure modes follow, and both are quiet:

  • Uncommitted output in generate makes every magus affected ci pay for work no gate can check. It looks like coverage and is not.
  • Committed output in build leaves a tracked file with nothing verifying it, so it drifts from its source indefinitely with every gate green.

When one phase feeds the other, build depends on generate rather than repeating it - the ordering falls out, and generate stays runnable on its own as the fast gate.

ci itself is an ordinary magusfile-defined target, not a hardcoded chain - you compose its stages yourself with magus\needs. What makes it the one required name is that 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. That is advice about keeping a vocabulary legible, not a constraint the engine enforces: reserve new names for genuinely distinct work (a deploy, release, or security) rather than fragmenting a phase that already has a name.

security is a worked example of a name worth adding, 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 rather than a naming preference. MGS1003 does not flag it.

What belongs under security is analysis of the DEPENDENCY INVENTORY - what the industry calls Software Composition Analysis - and that is two questions, not one:

  • Vulnerabilities. Advisory scanners over the dependency graph: govulncheck, pnpm audit, trivy image. Answers "does a dependency have a known flaw".
  • License compliance. Whether a dependency's terms can be combined with the project's own license. Answers "may this dependency legally be here at all".

They belong in one target because they share an input and a cache contract. Both read the same inventory, and both depend on data that lives OUTSIDE the tree - an advisory database, a license classifier - so neither can be cached against sources alone. That is the same skip_cache argument above, arriving twice.

They do not share a vocabulary, and the distinction is worth keeping. A license violation is a legal exposure, not a vulnerability, and the two are remediated differently: a vulnerability by upgrading, a license by dropping the dependency or relicensing your own work. Group them in the target; keep them distinct in the finding.

One trap when adding a license gate: a scanner's built-in license CATEGORIES assume a proprietary consumer. trivy classifies AGPL as forbidden and GPL/LGPL as restricted, which is correct advice for someone shipping closed source and wrong for a copyleft project, where those are the ordinary case. Calibrate the gate against your OWN license before turning it on - magus is GPL-3.0-or-later and ignores the compatible copyleft licenses explicitly, while still failing on the genuinely incompatible ones (GPL-2.0-only, which cannot be upgraded to v3, plus CDDL, EPL, and MPL-1.1). A gate that reports dependencies you are perfectly entitled to use is a gate someone will switch off.

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

When is a new name worth adding?

The names above are the recommended vocabulary, not a closed set - nothing stops you declaring any name the charset allows. The questions below are the ones worth asking before you do, because a name that fails them usually describes something that already has a home:

  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 slot of its own.
  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 weak claim on a name of its own.
  4. Tooling weight - some names carry engine semantics beyond "a bucket of ops": preflight and generate get drift-gating (see operations) when you declare them. That is a reason to reuse an existing name rather than invent a near-synonym, since the behavior attaches to the name.

The recommended vocabulary is deliberately small, and ci is the only member the engine requires. 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. typescript["tsc"] is an ordinary map-key lookup into the value import "magus/spell/typescript" 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 (94bfdf0b)
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.

Op

A single tool-native command a target composes (long form: operation); the middle of the work hierarchy (Spell to Op 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.

Lease

One row of the lease ledger: a piece of work an orchestrating agent handed out, with its goal, the checkpoint it was cut against, and the paths it owns or must not touch. The ledger records; the agent guard is what reads those facts back when grading a write. See doctrine.

Advisor

One read-only check from the advice suite: it reads the changeset through magus and writes one titled section of findings. The same advisors run as a pull request comment in CI and inside magus diff --impact locally.

Conventions

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