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 | api → api |
dot-relative (./x, ../x) |
current working directory | ../api → web/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.
Symlinks
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 doctorfails 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 afail, 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
generatemakes everymagus affected cipay for work no gate can check. It looks like coverage and is not. - Committed output in
buildleaves 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:
- Universality - the phase must mean something in every toolchain magus
adapts. A phase that only makes sense for one language fails this test:
typecheckis universal-sounding but Go and Rust type-check as part ofbuild, not as a separate phase, so it does not earn a slot of its own. - Distinctness - it must be a genuine phase, not a subset of an existing
one.
vet,audit, andtypecheckare all static analysis or formatting fragments oflint/format(see MGS1003), not phases of their own. A different CACHE CONTRACT counts as distinct: that is what separatessecurityfrom the fragments it otherwise resembles. - Pipeline membership -
cimust need to order it against the other phases. A step nobody'sciever sequences againstbuild/test/linthas weak claim on a name of its own. - Tooling weight - some names carry engine semantics beyond "a bucket of
ops":
preflightandgenerateget 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 funname 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.
fooBarandfoo_bar, both normalizing tofoo-bar) make magus refuse the magusfile, naming the offending pair. - Convention drift is a
doctorwarning, not an error. Mixed conventions across a workspace still resolve correctly, butmagus doctorwarns 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
Goand one namedgoare 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 valueimport "magus/spell/typescript"binds, so it must name the canonical (kebab) key. Likewisego::lintis still a graceful no-op - the go spell's linter op isgolangci-lint, and that is a different word, not a different casing. See spell-qualified targets. - Project paths.
Pathis never normalized;apiandApiare 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-lintruns that op.go::lintis a graceful no-op: the go spell has no op namedlint(its linter op isgolangci-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\needsversusdepends_on, and how a cross-projectneedsfolds into the affected set and the cache key. - dependencies.md, pattern forms: the
ctx.globgrammar - 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.