Documentation
One command, six checks, no configuration file. This page covers what each check looks for, what it deliberately ignores, how the paid check is unlocked and how to make the whole thing a gate in CI.
Requirements
- Node 22 or newer. Nothing older, because the code uses what 22 ships.
giton the path, plus a real git repository. Every check starts fromgit ls-files, so a plain directory returns nothing rather than guessing.- No configuration file, no API key, no account for the free checks.
Zero runtime dependencies. The whole scanner is one directory of ES modules, so an install cannot break your tree and there is no transitive package to audit.
Install
There is nothing to install. This is the whole quickstart:
npx margyn-scan /path/to/repo
If you would rather have it on the path or pinned in a repository:
npm install -g margyn-scan # then the command on your path is: margyn npm install -D margyn-scan # then, inside that repo: npx margyn .
The package is margyn-scan because npm refuses the name
margyn as too close to an existing package called morgan. The command it installs
is margyn, so the tool and the command agree even though the package name has to
carry a suffix.
Usage
npx margyn-scan [path] [options] # zero install margyn [path] [options] # once it is on your path path repository to scan. Defaults to the current directory --mutate run the mutation proof too. Part of Watch, so it needs a licence --prove run each finding's own proof, certify what reproduces, retract the rest --max=<n> how many mutations to try. Defaults to 4 --json print the findings as JSON instead of text --sarif-out=<file> also write SARIF 2.1.0 for GitHub's Security tab --comment-out=<file> also write a Markdown report for a PR comment or summary --version print the version --help print the usage above
Exit code is 1 when anything was found and 0 when nothing was. That is the whole
CI contract, so no wrapper script is needed. An invalid --max exits 2 rather than
quietly falling back to the default, because a typo that scans four files while you believe it
scanned forty is the same class of defect this tool reports.
Proof mode
Every finding already ships a reproduction. --prove runs it for you. For each
finding Margyn executes the read-only proof its check emitted, checks the output carries the
markers the finding predicted, and labels it reproduced. A finding it cannot reproduce is
retracted and dropped, so a gate never fails your build on a claim the tool could not show
on your own tree. The mutation proof is reported as observed, because it was already
established by running your suite. It is free: it makes a finding undeniable, which is the whole
product.
npx margyn-scan . --prove
1. vendor/dist/IPool.mjs is read but git ignores it HIGH ignored-source REPRODUCED
MARGYN_ABSENT_FROM_HEAD
MARGYN_PRESENT_ON_DISK
2 findings: 2 reproduced.
Reading the output
margyn /tmp/moss 2 findings, each with a reproduction you can run. 1. packages/protocols/aave/abis-src/dist/AaveV3Monad.mjs is read by packages/protocols/aave/README.md but git ignores it HIGH ignored-source packages/protocols/aave/abis-src/dist/AaveV3Monad.mjs ignore rule: .gitignore:2:dist/ why: A clean clone or a CI runner cannot read this file. reproduce: git -C . archive HEAD | tar -t | grep -qx '<path>' || echo 'ABSENT from HEAD' test -f '<path>' && echo 'PRESENT on disk'
Six parts, in this order: the summary, the severity, the check that fired, the file, the evidence the check based it on, why it matters, then the reproduction. The reproduction is the part that makes it a finding rather than an opinion. A finding that cannot carry one is dropped instead of printed, so the count you see is smaller than the count we could have printed.
Findings are sorted with high first. Severity is a word, never a colour on its own, so the output survives being piped into a file or read by someone who does not see red.
The six checks
Each one says what fires it and what does not, because a scanner you cannot predict gets uninstalled. The precision rules below are not tuning knobs, they are fixes for false positives we produced and treated as defects.
ignored-source high
Fires when a file on disk is excluded by an ignore rule, is not tracked by git, and some tracked source file mentions its path. Then a clean clone cannot read it, so your green local run and a red CI run are both correct.
Does not fire when:
- The mention comes from a
package.jsonnaming its own build output inmain,exportsorbin. Declaring your output is not reading it, so only real source counts as a reader. - Something in the commit answers the path the reader asks for. An asset committed at
web/public/tour/clip.webmsatisfiessrc="/tour/clip.webm", so the copy your build left inweb/distis not missing source. Every path a reader names is collected and only one that nothing commits is reported. - The file sits under output a tool in this repository declares it writes:
outinfoundry.toml, avite buildornext buildorcargo buildin the script that runs it, anoutdirin a build script, anoutDirin atsconfig. A clean clone plus that build has the file. The rule reads declarations and resolves them to real paths, never the directory being calleddist: the defect this check was written from was vendored source invendor/dist, which no tool declares, and that one still fires. - The match is only a bare filename. A path suffix carrying at least one parent directory is
required, otherwise every
dist/index.jsin a monorepo gets reported. The real defect this check was written from still matches, because it was found by three segments:dist/abis/IPool.mjs. - The file sits inside a dependency tree an install step fetches, for example
forge installintocontracts/lib. Those are ignored on purpose and recreated on demand. Detected by a manifest of their own inside an untracked ancestor. - Nothing references the file at all. An ignored build artefact is not a defect.
The evidence names the rule and the line, for example .gitignore:2:dist/, so you can
fix the rule rather than hunt for it.
no-assertion high
Fires when a test(...) or it(...) call contains no
assertion anywhere in its span. That test runs your code, throws nothing, then reports green
whatever the code returned.
Does not fire when:
- The assertion is reached through a helper. Any identifier containing expect or assert counts,
so a test whose whole body is
expectTreeError(...)is left alone. - A helper is handed the test context, for example
checkRequestValues(t, req, { ip }). The assertion lives in the helper and the helper needs the context to make it, so this is the same rule as above without depending on the helper's name. - The body declares an assertion count.
t.plan(11)fails the test when the count comes up short, in node:test, tap, tape and ava alike, so a body carrying one cannot be hollow. - The file is a type level test.
@ts-expect-error,expectTypeOf,assertTypeorsatisfiesanywhere in the file means the checking happens at compile time. - The body is too small to be doing anything, under 24 non-space characters.
The whole balanced parenthesis span of the call is searched rather than a guess at where the
callback body starts, because it("x", { timeout: 1 }, fn) puts an options object
exactly where a naive parser looks for the body and would report every timed test.
The middle two rules came from running this check over fastify, where seven tests in one file were reported and every one of them was wrong. That run is on the proof page, before and after.
cannot-fail high
Fires when a test asserts something that cannot be false, so it reports green whatever the code does. no-assertion reports an empty body; this one reports a body full of assertions that hold by construction. Three shapes, each one measured on real repositories before it shipped:
- A literal assertion in the catch.
catch (e) { expect(true).toBe(true) }answers the failure path with something already true, so the assertions in the try are swallowed and the test passes with its subject down. - A literal assertion that is the test's only one.
it("documents the handler", () => { expect(true).toBe(true) })can only fail by throwing. - An assertion inside a try whose catch cannot fail the test, and its variant, a
deliberate fail marker whose catch is satisfied by the marker's own error.
try { await call(); expect(true).to.be.false } catch (e) { expect(e).to.exist }passes on both paths, because the marker throws an assertion error and the catch is happy that an error arrived. - A status list that spans success and failure.
expect([200, 302, 400]).toContain(res.status)cannot tell the outcomes apart, so the endpoint can start failing with this test still green.
Does not fire when:
- The test declares an assertion count. Under
t.plan(n)a swallowed assertion normally changes the count and the plan goes red. Sometimes the count coincides and the test really cannot fail, but which one it is depends on how many assertions each path makes, and guessing that from text is how a scanner earns its reputation. This rule fired 41 times on fastify before the gate and 39 of those were wrong. - The catch hands the error to code after the try.
catch (err) { thrown = err }followed byexpect(thrown).toBeInstanceOf(...)is the correct way to assert on a rejection. - The catch checks which error arrived: its message, its name, its code, its type. That separates a real rejection test from a marker satisfied by its own error.
- The literal assertion sits beside a real one. A "we got here" marker next to a catch that
calls
fail()is not a hollow test. - The status list is all failures or all successes. A list of 400, 401 and 403 is a deliberate negative test.
- The vacuous code is inside a string or a comment. Comments, string contents and regex bodies are blanked before anything is matched, because a suite that compiles code holds vacuous snippets as data.
Two shapes were measured and dropped rather than shipped. A top level || in an
assertion was right twice in 110 real sites, because what decides the class is what the constants
mean. An empty catch on its own was wrong 14 times out of 14, because the assertion normally sits
after the try. Neither is a tuning knob we left off; both are rules that would have cried wolf.
unrun-check medium
Fires when a script whose name starts with test, lint, typecheck, check, verify,
audit or e2e is declared in a package.json and no workflow file mentions it and no
sibling script calls it. It reads as coverage in the repository and it cannot fail.
Does not fire when:
- There is no
.github/workflowsdirectory at all. With no CI to compare against, "nothing invokes it" would be true of every script and the check would be noise. - A sibling script mentions it, however your package manager spells it.
pnpm check:web,npm run check:webandturbo run check:weball count. - It is an npm lifecycle script such as
prepareorpostinstall. npm runs those itself, so absence from CI proves nothing.
Workspaces are covered: packages, apps and examples are
scanned one and two levels deep.
lint-blindspot medium
Fires when a linter or formatter config takes its exclusions from the ignore file
rather than from its own config. Today that is biome's useIgnoreFile and any
ignorePath, in biome.json, biome.jsonc,
.eslintrc.json, eslint.config.js or .prettierrc.
The exclusion is then a side effect. A path that becomes tracked silently enters the tool's scope, which can rewrite vendored bytes whose hash was the thing proving they came from upstream. That is not hypothetical: it is the second half of the failure this product was written from.
mutation high part of Watch
Fires when a line is inverted, your whole suite runs, then it passes anyway. There is no arguing with a test that passed while the thing it guards was inverted. Details in the next section.
The mutation proof
npx margyn-scan . --mutate # four mutations, the default npx margyn-scan . --mutate --max=12 # more mutations, more full test runs
How a candidate is picked, in order:
- Your suite must pass unmutated. If the baseline is red the check aborts and says so, because a mutation result against a red suite means nothing.
- Candidates come from
git ls-files, filtered to.js,.mjs,.tsand.mts, skipping tests, type declarations,dist,node_modulesand anything that looks like a fixture. - The first mutation that applies to the file is used, from this list:
return truetofalse,return falsetotrue,===to!==,!==to===,>=to<,<=to>,&&to||. Each one inverts meaning without changing shape, so nothing fails to parse. - The suite runs again. If it passes, that is a finding.
The test command is whatever the scanned repository declares, run as npm test --silent.
A repository with no test script gets no mutation findings rather than a guess. Each run is timed
out at three minutes. The file is restored in a finally block and on
SIGINT.
We never print a mutation score. The output is the surviving line and the command that reproduces it. If a score across a whole codebase is what you want, that is Stryker, free and better at it than we are.
Licences
Buy Watch, sign in, then press Get my licence in the top bar. You are handed one line of text. The CLI looks for it in two places, environment first so CI can inject it as a secret:
export MARGYN_LICENCE='<the line>' # or MARGYN_LICENSE, both are read ~/.margyn/licence # or $MARGYN_HOME/.margyn/licence
It is verified offline against a public key compiled into the CLI, so a paid check runs on a runner with no network access. Licences last 31 days. Take a new one whenever you like while the subscription is active, which is also how a lapsed subscription stops working on its own.
A Team subscription mints a licence that carries the same capability as Watch, so the mutation proof unlocks either way. A Fix flow subscription unlocks nothing in the binary, which the licence says rather than implies: it is work delivered by a person, not a feature flag.
Every refusal names itself rather than collapsing into a single unhelpful no:
no licence found licence expired on 2026-09-06 licence signature does not match, so this licence was not issued by us this licence covers watch, not fixpack
A refusal never fails your run. The reason is printed, the free scan runs in full, and the exit code still reflects your findings rather than your billing.
In CI
Exit code 1 on findings is the whole integration, so this is the entire GitHub Actions step:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npx margyn-scan .
With the mutation proof, pass the licence as a secret. Keep it on a schedule or on pull requests to main rather than on every push, because it runs your suite once per mutation:
- run: npx margyn-scan . --mutate --max=8
env:
MARGYN_LICENCE: ${{ secrets.MARGYN_LICENCE }}
Two things worth knowing before you add it to a required check. Margyn reads the repository as it
is checked out, so a checkout that omits files hides exactly the defect
ignored-source exists to find. And the licence secret belongs in the repository or
organisation secrets, not in the workflow file, for the reason on the
security page.
Findings on the pull request
The Margyn action can post the findings where developers look. It
writes a job summary every run, and on a pull request it will keep one comment updated in place and
upload SARIF to the Security tab. It uses the job's own GITHUB_TOKEN, so nothing is
hosted and no secret leaves your repository.
permissions:
contents: read
pull-requests: write # for the comment
security-events: write # for the Security tab
steps:
- uses: actions/checkout@v4
- uses: zkasuran/margyn@v0
with:
comment: true
sarif: true
Both are off by default and both are best-effort: if a comment or an upload fails, the step warns and still fails the job on findings, because the audit result is the exit code, not the comment.
JSON output
{
"root": "/path/to/repo",
"findings": [
{
"check": "ignored-source",
"severity": "high",
"file": "packages/aave/abis-src/dist/IPool.mjs",
"summary": "... is read by ... but git ignores it",
"evidence": "ignore rule: .gitignore:2:dist/",
"why": "A clean clone or a CI runner cannot read this file.",
"reproduction": ["git -C . archive HEAD | tar -t | grep -qx ...", "test -f ..."]
}
],
"gate": { "mutation": "locked", "reason": "no licence found" }
}
gate is present only when --mutate was asked for. It reports whether
the paid check ran. Use it to post findings on a pull request instead of failing the job. The exit
code is unchanged by --json.
When it finds nothing
$ npx margyn-scan /path/to/repo margyn /path/to/repo Nothing hollow found. Every check this tool knows how to test held up.
Then your verification layer held up on the six things this tool knows how to test, which is worth knowing and cost you one command. It is a narrow tool on purpose. It has six checks, it says so, and it does not invent a seventh to make a report look busy.
If it missed something it should have caught, or reported something that was fine, say so in the suggestion box. A false positive is treated as a defect here rather than as a tuning preference, so those are the most useful messages we get.