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

MGS4007: undeclared source modified

A target finished successfully, and one of the files it declared as a source had different content afterwards than before. The target did not declare that file with ctx.modifiesExistingFiles(...).

[MGS4007] .:format modified its declared sources types/diagnostic.go; declare
them with ctx.modifiesExistingFiles(...)

The message names the declaration; when writing is not the target's job, the other fix is to stop writing (see Fixing it). A terminal prints the reason dim under the cause: a step that rewrites its own declared sources changes its cache key as it runs. An agent's display leaves that line in the run log unless run with -v. When several steps in one run hit this code, the first prints in full and the run's footer names the rest on one line.

Why this is not a style rule

A cache key is a claim: these inputs produced this result. Sources are hashed before the target runs, so a target that rewrites its own sources invalidates that claim in the same breath it makes it. The stored entry describes a tree that no longer exists, and nothing can reproduce it: not a teammate, not CI, not the same machine a second later.

magus already noticed. recordOutput recomputes the key after every run and declines to persist the key's explanation when it disagrees, because those lines would blame the wrong input. That branch has always existed; until MGS4007 it only whispered at debug level, so the run still passed and the entry was still stored.

The charm trap this was built for

Charms patch a command's arguments. A charm cannot say whether the resulting tool writes, which is exactly how a check turns into a write with nothing in its name changing:

default_charms: [rw]        # magus.yaml - every local run carries it
"rw": append(["-w"])        # the op's charm - the tool's autofix flag

Compose those and a target called lint rewrites the tree on a developer's machine, while CI, which strips default charms, runs the identical target read-only. Neither run can reveal the other. That divergence is the defect; the spelling of the charm is not.

So this code is charm-agnostic on purpose. Keying it on the charm was considered and rejected twice over: a rule that exempts a charm the caller typed lets a mutating custom charm through merely because someone spelled it out, and a rule that exempts rw specifically says nothing about the charms a workspace defines for itself.

Fixing it

Declare the edit, when writing is the point. A formatter genuinely modifies existing files, and ctx.modifiesExistingFiles is the declaration that says so:

import "magus";

export fun format(ctx: magus\Context, args: [str]) > void {
    ctx.modifiesExistingFiles("**/*.go", "go.mod", "go.sum");
    go["go-fmt"](ctx);
    go["go-mod-tidy"](ctx);
}

Declare it unconditionally, at the top of the target, even when a charm decides whether the edit lands, the same shape spells/golang/gomod.buzz uses for go.mod. The globs fold into the source set either way, so the check-only variant is unaffected.

Or stop writing, when it is not. A target named for checking should not carry a write. Hang the autofix off its own charm rather than a workspace default, so it happens because someone asked:

return Command{bin = "typos", args = args, charms = {
    "fix": append(["-w"]),   // not "rw": that one is a default charm
}};

Why modifiesExistingFiles rather than writesFiles

writesFiles declares an output: magus snapshots it, replays it on a cache hit, and magus clean removes it. That is wrong for a file the target amends but did not author: a replay would restore stale bytes over someone else's edit, the two-owner hazard MGS1020 reports.

modifiesExistingFiles records read-and-amend instead: the globs join the source set (so editing the file still invalidates the target that maintains it) and stay out of the output set (so nothing snapshots, replays, or cleans them).

What it checks, and what it does not

The check runs only after the target succeeded. A failed target has already reported why it failed, and a second diagnostic on top would bury it.

It compares content, not mtimes: a tool that rewrites byte-identical output has changed nothing a cache key can observe, and reporting it would train readers to ignore the code.

A source that disappeared counts: deleting an input is a write, and the key then describes a file that is not there. A source that appeared does not: a target may legitimately produce a file some broad source glob would have matched, which is MGS1028's question rather than this one.

Only declared sources are visible. A target that rewrites a file no glob claims is invisible here, the same limit MGS1020 records for static output checks.

See also

  • MGS1020: two targets declaring one output, the ownership violation this one is the runtime counterpart to.
  • MGS1028: a file read but not declared, the under-declaration this one mirrors on the write side.
MGS4007racecachesourcescharmsformatteroutputsdeclarations
Last updated (f01f3de8)
Earlier changes on this page (3)

Full history ↗ · Blame source ↗

Glossary

Workspace

The magus root directory that owns a set of projects and shared config; the unit magus operates over. Workspace in full.

Magusfile

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

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. Target in full.

Op

A single tool-native command a target composes (long form: operation); the middle of the work hierarchy (Spell to Op to Target). Op in full.

Spell

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

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. Charm in full.

Ward

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

Buzz

The language magusfiles are written in (the .buzz engine). Buzz in full.

Cache

The content-addressed store magus consults before running a target, so unchanged work is skipped. Cache in full.

Affected

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

CI

An ordinary target you compose yourself with ctx.needs; magus does not hardcode its stages. It is the anchor magus affected ci keys off, and ci strips the rw charm. CI in full.

Snapshot

A point-in-time view of live state (the pool's occupancy or a tick of exported metrics), as opposed to accumulated history. Snapshot in full.

Ownership

An insight lens for author concentration: the primary author and their share, the distinct-author count (the bus factor), and abandonment. Ownership in full.

Agent

A model working through an agent host such as Claude Code, Codex, Cursor or OpenCode. magus treats an agent as one more caller of its CLI, MCP tools and console, held to the same checks as a person. Agent in full.

Tool

An action a model can call through its host: a name, a description, a typed input and a result. magus serves its tools over MCP, each a read of the workspace model or a run of the CLI it already has. Tool in full.

Job

The unit of delegated work, and one row of the job store: what an orchestrating agent handed out, with its goal, the checkpoint it was cut against, the paths it may write or must not touch, and the one check it runs. A job's holder is either a session, for work an orchestrator handed out, or the daemon, for its own maintenance. The store records; the agent guard is what reads those facts back when grading a write. Job in full.

Run

One target executing under one magus invocation, such as magus run test web or magus affected ci. A run keeps its captured output behind an output reference. Every magus run is a run whether or not any job asked for it; see Job for how the two relate. Run in full.

Conventions

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