Margyn
Colour theme
Get Watch

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.
  • git on the path, plus a real git repository. Every check starts from git 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.json naming its own build output in main, exports or bin. 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.js in 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 install into contracts/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, assertType or satisfies anywhere 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/workflows directory 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:web and turbo run check:web all count.
  • It is an npm lifecycle script such as prepare or postinstall. 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

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:

  1. 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.
  2. Candidates come from git ls-files, filtered to .js, .mjs, .ts and .mts, skipping tests, type declarations, dist, node_modules and anything that looks like a fixture.
  3. The first mutation that applies to the file is used, from this list: return true to false, return false to true, === to !==, !== to ===, >= to <, <= to >, && to ||. Each one inverts meaning without changing shape, so nothing fails to parse.
  4. 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.

Pricing · Security model · Source on GitHub