Documentation
One command, five 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 --max=<n> how many mutations to try. Defaults to 4 --json print the findings as JSON instead of text --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.
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 five 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. - 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.
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.
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 five 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 five checks, it says so, and it does not invent a sixth to make a report look busy.