All posts

Releases

Introducing Shipmoor Community CLI: verify agent work before review

A free, local, offline command-line tool that scans Python, TypeScript/JavaScript, and Go for the defects AI coding agents actually leave behind. Built around the moment between 'the agent finished' and 'I'm asking a human to review this.' Includes live runs against Flask, Zod, and Cobra.

Introducing Shipmoor Community CLI: verify agent work before review cover image

If you’ve used an AI coding agent to write production code, you already know the pattern. The agent finishes. The diff looks plausible. You read it once, twice, and ship it to review. A reviewer skims it, approves it, and merges. Three days later something breaks in a way that is hard to track down, and when you go look, the answer is almost embarrassing.

from xyzlib import summarize. The catch: xyzlib does not exist on PyPI.

panic("TODO: implement privileged container enforcement"). Left in the merged code.

// @ts-ignore over a real type error, with no explanation.

except: pass around the database write.

These are not exotic defects. They are the small set of mistakes that AI coding agents make characteristically: plausible-looking imports for packages that do not exist, stub functions that survive review, suppressions that hide real type errors, swallowed exceptions, and panic("TODO") strings that compile cleanly and ship.

We built Shipmoor Community CLI to catch them in the moment between “the agent finished” and “I’m asking a human to review this.” It is free, local, offline, and small enough to read in an afternoon.

curl -fsSL https://dl.shipmoor.dev/install-community-cli.sh | bash

Today we are introducing the tool with v0.2.0. The rest of this post walks through every part of it: the philosophy, the rules, the five scan modes, the output contract, the CI gate, the patch-mode workflow, and live runs against Flask, Zod, and Cobra.

For an always-current, diagrammed walkthrough of the same machinery — the five stages, the phantom-import check, the finding contract, the gate — see How the Shipmoor scan works.

Why timing matters more than coverage

Most static analyzers earn their place by being comprehensive: hundreds of rules, deep dataflow, security databases. Shipmoor is not trying to compete with them. There is no point: ruff, ESLint, go vet, semgrep, CodeQL, and pip-audit are all excellent.

The gap they leave is not “more rules.” It is a specific moment in the workflow: after the agent writes code, before you ask a human to review it. The conventional tools run in the wrong direction. Linters run on every save. Vulnerability scanners run on every dependency change. SARIF dashboards run on every push. None of them are built around “the agent just finished, what did it do.”

The defects that survive at that moment are not style issues; formatters fix those. They are not CVEs; vulnerability scanners catch those. They are the small, characteristic, plausible-looking mistakes that a generated function is more likely to produce than a tired engineer. A human under deadline will copy-paste a real package name. An agent without context can confidently invent one.

Shipmoor’s Community CLI is built around that moment. It runs in under a second on most changes. It only looks at the diff. It produces output that names the next step.

The shape of the tool

The CLI does one thing: it runs a scan and reports findings. There is no daemon, no log file, no account, no telemetry, no network call that is not the optional package-registry lookup for hallucination detection. You install one binary, you run it, you read what it says. If you don’t like what it says, the JSON output is stable enough to script around.

The full command surface is small:

shipmoor init                   # generate a starter .shipmoor.yaml
shipmoor scan [target]          # scan a file, directory, or implicit cwd
shipmoor rules                  # list every rule the CLI knows about
shipmoor explain <id> --from <report.json>  # explain a single finding
shipmoor version

scan is the only command that takes meaningful flags. The five scan modes are:

FlagScopeWhen you use it
no flag (path)A file or directory treeFirst time you point Shipmoor at a project, or for full-repo sweeps.
--changedStaged + unstaged git changesThe default workflow. After the agent finishes locally, before you make a PR.
--stagedStaged changes onlyPre-commit hooks where you don’t want unstaged WIP to interfere.
--diff <range>Files changed in a Git diff rangeCI gates: --diff origin/main...HEAD.
--patch <file>Files and changed ranges from a unified diff”An agent handed me a patch. What is in it before I apply it?”

When you run shipmoor with no subcommand, you get a status banner with the repo name, branch, changed-file count, detected languages, and the suggested next command:

> shipmoor



     ███████ ██   ██ ██ ██████  ███    ███  ██████   ██████  ██████
     ██      ██   ██ ██ ██   ██ ████  ████ ██    ██ ██    ██ ██   ██
     ███████ ███████ ██ ██████  ██ ████ ██ ██    ██ ██    ██ ██████
          ██ ██   ██ ██ ██      ██  ██  ██ ██    ██ ██    ██ ██   ██
     ███████ ██   ██ ██ ██      ██      ██  ██████   ██████  ██   ██

         v0.2.0  community  ·  verify agent work before review
     ───────────────────────────────────────────────────────────────
     metaphor  ·  main  ·  1 changed  ·  ts · js

     next  shipmoor scan --changed    ·    shipmoor help

The banner adapts to four states (first-run, standard, clean, no-git), three layouts (full, status-only, compact), and is suppressed on non-TTY output. The “next” line always points to the command that does the right thing from the directory you are standing in. On a clean repo it suggests shipmoor scan --diff <default-branch>..HEAD. Outside a git repo it suggests shipmoor scan --patch agent.patch.

The rule catalog

v0.2.0 ships 30 rules across four languages (Python, TypeScript, JavaScript, Go) organized into five categories. The full list:

$ shipmoor rules
python.syntax_error                           critical Python syntax error
python.phantom_import                         high     Phantom Python import
python.placeholder.empty_body                 medium   Empty Python body
python.placeholder.constant_return            medium   Constant Python return
python.quality.mutable_default                medium   Mutable default argument
python.quality.bare_except                    low      Bare except
typescript.phantom_dependency                 high     Phantom npm dependency
typescript.placeholder.empty_function         medium   Empty TypeScript function
typescript.placeholder.not_implemented        medium   Not implemented throw
typescript.trust.any_boundary                 medium   Any public boundary
typescript.trust.as_any                       low      As any cast
typescript.trust.ts_ignore                    medium   ts-ignore suppression
typescript.debug.console                      low      Console debug output
typescript.control_flow.unreachable_code      medium   Unreachable JavaScript code
javascript.phantom_dependency                 high     Phantom npm dependency
javascript.placeholder.empty_function         medium   Empty JavaScript function
javascript.placeholder.not_implemented        medium   Not implemented throw
javascript.trust.any_boundary                 medium   Any public boundary
javascript.trust.as_any                       low      As any cast
javascript.trust.ts_ignore                    medium   ts-ignore suppression
javascript.debug.console                      low      Console debug output
javascript.control_flow.unreachable_code      medium   Unreachable JavaScript code
go.phantom_import                             high     Phantom Go import
go.placeholder.empty_function                 medium   Empty Go function
go.placeholder.todo_comment                   low      TODO/FIXME comment in Go source
go.placeholder.todo_panic                     high     Placeholder Go panic
go.error.ignored_error                        medium   Ignored Go error
go.error.panic_error                          medium   panic(err)
go.debug.fmt_print                            low      fmt.Print debug output
go.structure.god_function                     medium   Large Go function

The five categories, with the agent failure mode each one targets:

Phantom imports / dependencies (python.phantom_import, typescript.phantom_dependency, javascript.phantom_dependency, go.phantom_import). The agent invented a package, mis-spelled one, or used one without declaring it in the project manifest. This is the headline rule. v0.2.0 classifies every phantom finding with a subtype:

  • hallucinated_package: there is no such thing on the package registry.
  • missing_manifest_entry: the package exists, but the project did not declare it.
  • broken_relative_path: the import is local, but the file does not exist on disk.
  • unresolved_local_module: the import looks local but does not match any module under the project’s source roots.

The four subtypes are distinguishable in the JSON output and in the human-readable message text, so a reviewer can tell at a glance whether the agent invented something or just forgot to update a manifest.

Placeholder logic (*.placeholder.*). Functions whose body is pass / ellipsis / throw new Error("not implemented") / panic("TODO") / a constant return. The agent stubbed something and never came back.

Trust suppression (*.trust.*). any, as any, @ts-ignore, @ts-expect-error. Code that bypasses the type system to make a generated change “compile” without satisfying the type contract. These are conservative-severity rules: @ts-ignore is medium, isolated as any is low. Legitimate uses exist.

Quality signals (*.quality.*, *.debug.*, *.structure.*). Bare except, mutable defaults, fmt.Print / console.log left in production code, large functions. The agent emitted code that works but a reviewer would clean up.

Control flow (*.control_flow.*). Unreachable code after a return or throw. v0.2.0 scopes this analyzer to function bodies so it doesn’t fire on every export default Component line.

Severities are aligned across languages by an explicit cross-language policy. A hallucinated import is high in Python, high in TS/JS, high in Go. A placeholder panic / not-implemented throw is high. A trust suppression at a public boundary is medium. Debug output is low everywhere.

Live runs against well-known projects

We cloned Flask, Zod, and Cobra at --depth 1. For each we planted one file that an agent might plausibly produce, then ran shipmoor scan --changed. The intent is to demonstrate the workflow Shipmoor is actually pitched on: scope the scan to the agent’s change, get a focused list of next steps.

Flask (Python)

We added src/flask/ai_helpers.py:

"""AI-assisted helpers for Flask request handling."""

from incidentlib.ai import summarize
from sqlalchemy.orm import Session


def build_payload_summary(payload, tags=[]):
    tags.append(payload.get("kind"))
    return summarize.compact(payload, tags=tags)


def record_audit(session: Session, request_id: str, text: str) -> None:
    # TODO: implement actual persistence
    pass


def safe_record(session: Session, request_id: str, text: str) -> None:
    try:
        record_audit(session, request_id, text)
        session.commit()
    except:
        pass
$ git add src/flask/ai_helpers.py
$ shipmoor scan --changed
Review readiness: Needs work. 5 findings on your change (2 high, 2 medium, 1 low).
Project context: pyproject.toml (8 deps), examples/celery/requirements.txt (21 deps), examples/celery/pyproject.toml (2 deps), examples/javascript/pyproject.toml (2 deps), examples/tutorial/pyproject.toml (2 deps). Scanning 1 files.
Scope: --changed · 1 file
  src/flask/ai_helpers.py
Shipmoor scanned 1 files and found 5 findings.
Gate threshold: high
high     [would block] src/flask/ai_helpers.py:7 python.phantom_import - Package 'incidentlib' does not exist on PyPI.
  Recommendation: No package named 'incidentlib' exists on PyPI. Ask the agent to use a real package or remove the import.
high     [would block] src/flask/ai_helpers.py:8 python.phantom_import - Package 'sqlalchemy' is imported but not declared in requirements.txt or pyproject.toml.
  Recommendation: 'sqlalchemy' is used but not declared in requirements.txt or pyproject.toml. Add it or remove the import.
medium   src/flask/ai_helpers.py:11 python.quality.mutable_default - Function 'build_payload_summary' uses a mutable default argument.
  Recommendation: Replace `def build_payload_summary(tags=[])` with `def build_payload_summary(tags=None)` and initialize inside the function body.
medium   src/flask/ai_helpers.py:21 python.placeholder.empty_body - Function 'record_audit' has no meaningful implementation.
  Recommendation: Function `record_audit` has no implementation. Either remove the function or implement it before merging.
low      src/flask/ai_helpers.py:30 python.quality.bare_except - Bare except catches all exceptions.
  Recommendation: Replace bare `except` with `except Exception` and re-raise or log appropriately.

A few details worth reading carefully:

  • The first line is the review-readiness verdict: “Needs work” because at least one finding meets the gate threshold (high). The other two states are “Ready” (no findings) and “Needs a look” (findings, none above the gate).
  • The project context line lists every manifest Shipmoor discovered. Flask’s source has a top-level pyproject.toml plus separate manifests in each example. Shipmoor reads all five and uses each one when resolving imports in its directory.
  • The same rule (python.phantom_import) produces two different message texts, because v0.2.0 distinguishes subtypes: incidentlib is hallucinated_package (“does not exist on PyPI”); sqlalchemy is missing_manifest_entry (“imported but not declared”). A reviewer can tell at a glance whether the agent invented something or just forgot to declare it.
  • The recommendations are finding-level, not rule-level. Each one names the function or expression it is about, with the actual code, and tells the reader what to do.
  • The [would block] prefix only appears on findings at or above the gate threshold. Lower-severity findings appear without it, so the gate impact is unambiguous.

Zod (TypeScript monorepo)

Zod is a TypeScript monorepo with manifests in seven subdirectories. We added packages/zod/src/ai_schema.ts:

import { z } from "fake-zod-extra";

import type { ZodType } from "./index";

export interface AiSchemaInput {
  payload: unknown;
}

export function parseAiPayload(input: any): any {
  // @ts-ignore
  return z.object({ title: z.string() }).parse(input);
}

export function ackPayload(_schema: ZodType<unknown>): never {
  throw new Error("not implemented");
}
$ git add packages/zod/src/ai_schema.ts
$ shipmoor scan --changed
Review readiness: Needs work. 4 findings on your change (1 high, 3 medium).
Project context: package.json (35 deps), tsconfig.json, packages/bench/package.json (6 deps), packages/bench/tsconfig.json, packages/docs/package.json (24 deps), packages/docs/tsconfig.json, packages/integration/package.json (7 deps), packages/integration/tsconfig.json, packages/resolution/package.json (4 deps), packages/resolution/tsconfig.json, packages/treeshake/package.json (12 deps), packages/tsc/package.json (8 deps), packages/tsc/tsconfig.json, packages/zod/package.json (0 deps), packages/zod/tsconfig.json. Scanning 1 files.
Scope: --changed · 1 file
  packages/zod/src/ai_schema.ts
Shipmoor scanned 1 files and found 4 findings.
Gate threshold: high
high     [would block] packages/zod/src/ai_schema.ts:1 typescript.phantom_dependency - Package 'fake-zod-extra' does not exist on npm.
  Recommendation: No package named 'fake-zod-extra' exists on npm. Ask the agent to use a real package or remove the import.
medium   packages/zod/src/ai_schema.ts:9 typescript.trust.any_boundary - Exported function boundary uses any.
  Recommendation: `export function parseAiPayload(input: any): any {` exposes `any`. Replace it with a concrete or generic type.
medium   packages/zod/src/ai_schema.ts:10 typescript.trust.ts_ignore - @ts-ignore suppresses a TypeScript error.
  Recommendation: `// @ts-ignore` suppresses compiler feedback. Fix the type error or use @ts-expect-error with a narrow reason.
medium   packages/zod/src/ai_schema.ts:15 typescript.placeholder.not_implemented - Code throws a not-implemented placeholder error.
  Recommendation: `throw new Error("not implemented");` is a placeholder throw. Implement the path or remove the dead branch.

Two product points show up clearly here:

  • Monorepo support. Zod’s manifests live under packages/*/package.json. Shipmoor discovers all of them and uses each one as a resolution context. The legitimate relative import ./index (Zod’s own root module) is not flagged.
  • Quiet on real code. Across 406 sibling source files Shipmoor flagged exactly zero. The four findings are all on the file the “agent” wrote. The scope discipline is what makes the changed-mode pitch survive contact with real codebases.

Cobra (Go)

Cobra is the spf13 CLI library. We added ai_completion.go:

package cobra

import (
	"context"
	"fmt"

	"github.com/example/notreal/completion"
	"github.com/spf13/pflag"
)

// AIComplete sketches an AI-assisted completion path drafted by an agent.
func AIComplete(ctx context.Context, args []string, flags *pflag.FlagSet) ([]string, error) {
	_, _ = completion.Suggest(ctx, args)

	fmt.Print("[ai-complete] generating suggestions for ", args, "\n")

	panic("TODO: wire AIComplete to the agent harness")
}

func emitSuggestions(_ []string) {
	// nothing for now
}
$ git add ai_completion.go
$ shipmoor scan --changed
Review readiness: Needs work. 4 findings on your change (2 high, 1 medium, 1 low).
Project context: go.mod (module github.com/spf13/cobra). Scanning 1 files.
Scope: --changed · 1 file
  ai_completion.go
Shipmoor scanned 1 files and found 4 findings.
Gate threshold: high
high     [would block] ai_completion.go:7 go.phantom_import - Import 'github.com/example/notreal/completion' is not declared in go.mod, vendored, or part of this module.
  Recommendation: 'github.com/example/notreal/completion' is used but not declared in go.mod. Add it or remove the import.
high     [would block] ai_completion.go:17 go.placeholder.todo_panic - Go code panics with a not-implemented placeholder.
  Recommendation: `panic("TODO: wire AIComplete to the agent harness")` is a placeholder panic. Implement the path or return a typed error.
medium   ai_completion.go:13 go.error.ignored_error - Call result is discarded with a blank identifier.
  Recommendation: `_, _ = completion.Suggest(ctx, args)` discards a result. Handle the error or document why it is intentionally ignored.
low      ai_completion.go:15 go.debug.fmt_print - fmt.Print style debug output remains in source.
  Recommendation: `fmt.Print("[ai-complete] generating suggestions for ", args, "\n")` is debug output. Remove it or use an intentional logger.

go.phantom_import resolves imports statically against go.mod, the standard library, vendored packages, and the current module path. No go mod download required, no module cache hit, no network call. The planted hallucinated github.com/example/notreal/completion is flagged; the legitimate github.com/spf13/pflag (declared in Cobra’s go.mod) is not.

go.placeholder.todo_panic is one of the two Go rules promoted to high severity in v0.2.0 to align with the Python and TypeScript placeholder ceilings. A panic with “TODO” or “not implemented” in the message is now a --fail-on high block.

What full-tree scans look like on these projects

Changed-mode is the workflow that the product is actually pitched on, but it’s worth being honest about what happens when you point Shipmoor at the whole tree of a mature project. Here are the numbers without the planted files:

ProjectFiles scannedTotal findingsDistribution
Flask83187135 high python.phantom_import (mostly TYPE_CHECKING stubs and Sphinx-only build deps), 51 placeholder, 1 quality.
Zod4061792576 as any (library-legitimate generic gymnastics), 536 phantom_dependency (mostly .js extensions in TS imports and rollup/build configs), 390 unreachable_code, 170 console, 85 ts_ignore, 31 any_boundary.
Cobra3615156 god_function (bash completion generators), 47 fmt_print (CLI library legitimately printing), 38 ignored errors, plus a handful of placeholder + panic.

These numbers are not a verdict on Flask, Zod, or Cobra. These are excellent, mature projects. They are a verdict on full-tree scanning: a mature library has legitimate uses of patterns the rules flag, and the headline value of Shipmoor lives in the diff, not the tree. The default workflow is --changed or --diff precisely because that is where the signal-to-noise ratio is best.

A few of the findings in these tables are real bugs the rules don’t yet handle gracefully: Python if TYPE_CHECKING: imports and TypeScript .js-extension imports for .ts files. Both are on the v0.3 fix list below.

The output contract

Shipmoor emits three output formats from the same scan: human, JSON, and SARIF 2.1.0. The JSON contract is stable enough to script around:

{
  "schema_version": "shipmoor.scan.v1",
  "tool": {"name": "shipmoor", "version": "0.2.0", "information_uri": "https://shipmoor.dev", "rules": [/* ... */]},
  "scan": {"context": {"detected": true, "degraded": false, "summary": "pyproject.toml (8 deps), ...", "entries": [/* ... */]}},
  "summary": {"total_files": 1, "scanned_files": 1, "findings_count": 5,
              "counts_by_severity": {"critical": 0, "high": 2, "medium": 2, "low": 1, "info": 0},
              "counts_by_language": {"python": 5, "typescript": 0, "javascript": 0, "go": 0}},
  "findings": [
    {
      "id": "SHM-fd914abf5fdea281",
      "rule_id": "python.phantom_import",
      "language": "python",
      "severity": "high",
      "confidence": "high",
      "category": "phantom_dependency",
      "path": "src/flask/ai_helpers.py",
      "start_line": 7, "start_col": 1, "end_line": 7, "end_col": 37,
      "message": "Package 'incidentlib' does not exist on PyPI.",
      "root_cause": "The import name could not be found in the Python package registry.",
      "recommendation": "No package named 'incidentlib' exists on PyPI. Ask the agent to use a real package or remove the import.",
      "evidence": {"import_name": "incidentlib", "registry_lookup": "missing"},
      "change_status": "introduced",
      "fingerprint": "fd914abf5fdea2819c764e23b14c4bf07cf33b11587f423b37fba829596c002d",
      "subtype": "hallucinated_package"
    }
  ]
}

Things worth pointing out:

  • schema_version is explicit and will increment if the JSON shape changes incompatibly.
  • scan.context is what the human preamble is built from. degraded: true means no manifests were detected and resolvers ran without project knowledge.
  • fingerprint is a stable hash of the finding identity (rule + location + evidence). It survives line-number drift across commits and is what SARIF uses as partialFingerprints.shipmoorFingerprint.
  • subtype is the new v0.2.0 axis: it disambiguates phantom imports (hallucinated_package vs missing_manifest_entry vs broken_relative_path vs unresolved_local_module) and is the field your CI can route findings on.
  • change_status is introduced / modified / preexisting / unknown and answers “did this finding land in the changed lines?”. Combined with --diff and --patch modes, this is how Shipmoor enforces “only block on what the agent actually introduced.”

The SARIF output is a faithful 2.1.0 document with one run, a tool descriptor (shipmoor v0.2.0, 30 rules), and one result per finding. The fingerprint is carried through partialFingerprints.shipmoorFingerprint, and the level is the SARIF mapping of severity (error for high/critical, warning for medium, note for low/info). It is suitable for github/codeql-action/upload-sarif@v3 without any pre-processing.

Explain a single finding

If you have the JSON report, you can ask for one finding by ID:

$ shipmoor explain SHM-fd914abf5fdea281 --from shipmoor.json
SHM-fd914abf5fdea281 python.phantom_import [high]
Package 'incidentlib' does not exist on PyPI.
Root cause: The import name could not be found in the Python package registry.
Recommendation: No package named 'incidentlib' exists on PyPI. Ask the agent to use a real package or remove the import.
Location: src/flask/ai_helpers.py:7

After every JSON run that produced at least one finding, Shipmoor prints the exact explain command at the bottom of the human output. It is meant to be copyable.

Configuration

shipmoor init writes a starter config to .shipmoor.yaml:

schema_version: 1
languages:
  enabled:
    - python
    - typescript
    - javascript
    - go
ignore:
  - .shipmoor/
rules:
  disabled: []
  severity_overrides: {}
thresholds:
  fail_on: high
diff:
  only_introduced: true
output:
  default_format: human

Everything you need to keep noise down on an existing repo lives here:

  • languages.enabled lets you scope to one or two languages on a polyglot repo.
  • ignore takes glob patterns relative to the project root.
  • rules.disabled is an explicit list of rule IDs to skip entirely.
  • rules.severity_overrides lets you downgrade as any to info on a library that uses it intentionally, or upgrade bare_except to medium on a service that should not be swallowing.
  • thresholds.fail_on sets the gate threshold for non-zero exit code (critical, high, medium, or none).
  • diff.only_introduced: true restricts --diff and --patch modes to findings whose change_status is introduced.

The same fields are accepted on the command line (--fail-on, --no-color), and config values take precedence in the file > flag > default order.

Patch mode: the agent-handoff workflow

The clearest use case for Shipmoor is the one we recommend by default. You are working with an agent (Claude Code, Cursor, Aider, Codex, anything else). The agent finishes a change. You want a second pair of eyes before you commit it.

shipmoor scan --changed

There is a second workflow worth knowing about: when the agent hands you a patch that you haven’t applied. Maybe it’s a remote agent emitting a unified diff into a chat. Maybe it’s a PR you’re reviewing locally without checking out the branch. v0.2.0 lets you scan the patch directly:

shipmoor scan --patch agent.patch --fail-on high

Patch mode reads the unified diff, materializes the changed files in memory (no on-disk write), and runs the same analyzers. When the working directory contains a project root (package.json, pyproject.toml, requirements.txt, go.mod), patch mode consults that context for resolution; import React from "react" does not flag as phantom just because the patch alone doesn’t declare it. v0.2.0 establishes parity between --changed and --patch on the same logical change: identical fingerprints, identical findings, deterministic across runs.

The exit code (0 for clean, 1 for above-gate) is the same as --changed, so the same CI gate works either way.

CI integration

The GitHub Actions recipe is short. Install the binary, run the scan with --diff origin/main...HEAD, upload SARIF for the Security tab, and fail the workflow if there is a high-severity finding:

name: shipmoor
on:
  pull_request:
    branches: [main]
jobs:
  scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - name: Install Shipmoor
        run: |
          curl -fsSL https://dl.shipmoor.dev/install-community-cli.sh | bash
          echo "$HOME/.shipmoor/bin" >> $GITHUB_PATH
      - name: Scan changed files
        run: |
          shipmoor scan \
            --diff origin/main...HEAD \
            --fail-on high \
            --sarif --output shipmoor.sarif
      - name: Upload SARIF
        if: always()
        uses: github/codeql-action/upload-sarif@v3
        with:
          sarif_file: shipmoor.sarif

A few things worth knowing about the CI surface:

  • --diff origin/main...HEAD uses three-dot notation to scope to “what this branch adds relative to the merge base.” This means PRs do not get blocked on legacy findings the branch did not introduce.
  • --sarif + if: always() means the Security tab gets populated even on failing scans, so reviewers can see what blocked the PR without re-running.
  • Stable fingerprints mean a finding can be triaged once and that decision (via repo-level baseline tooling, if you have it) survives commits that move the same code around.

The full recipe, including a matrix run across multiple branches and a dependency-cache-friendly install path, is in docs/github-actions.md.

Project context and degraded mode

When Shipmoor runs, the very first line tells you what it knows about the project:

Project context: pyproject.toml (8 deps), examples/celery/requirements.txt (21 deps), examples/celery/pyproject.toml (2 deps), examples/javascript/pyproject.toml (2 deps), examples/tutorial/pyproject.toml (2 deps). Scanning 83 files.

If a repo has nested manifests (a common monorepo shape: backend/ and frontend/), v0.2.0 walks one level into the scan root and treats each subdirectory with a manifest as its own resolution context. This was the single biggest source of false positives in early testing: a Python+JS monorepo with no top-level manifest used to flood the resolver with phantom-dependency findings on every legitimately-declared dep.

If the resolver finds nothing, it says so explicitly:

Project context: none detected. Resolvers will run in degraded mode. Scanning 12 files.

“Degraded mode” means the resolvers will still run, but the headline rule (*.phantom_*) will be conservative: it can only flag truly missing imports, not “imported-but-not-declared” cases. The point of the preamble is to make this visible, so you are never confused about why the tool isn’t seeing something.

What v0.2.0 is intentionally not

There is a long list of things Shipmoor Community CLI does not do, and we want to be explicit about them:

  • It is not a style linter. ruff, ESLint, Prettier, gofmt, golangci-lint all do that job better.
  • It does not check vulnerabilities. pip-audit, npm audit, govulncheck, GitHub’s Dependabot all do that job better.
  • It is not a SAST tool. semgrep, CodeQL, Snyk Code do that job better.
  • It does not scan IaC. Dockerfile / Kubernetes / Terraform rules are on the v0.3 roadmap but not in this release. Today, IaC manifests in a scanned tree are silently skipped.
  • It is not a daemon. There is no background process, no file watcher, no editor integration in the Community CLI. Just one binary you run on demand.
  • It does not phone home. No telemetry, no analytics, no account. The optional package-registry lookup for the hallucinated_package subtype is the only outbound network call, and it can be disabled with SHIPMOOR_OFFLINE=1.

The scope is deliberately narrow. We will earn the right to extend it by being honest about what is there today.

What is coming

The short version of v0.3 priorities:

  • TYPE_CHECKING-aware Python imports. from _typeshed import StrPath inside if TYPE_CHECKING: should not be phantom.
  • Reverse-resolved .js imports in TypeScript. Node 16+ ESM lets you write ./foo.js to mean ./foo.ts; v0.2.0 doesn’t yet reverse-resolve those, and Zod’s full-tree numbers show why this matters.
  • Comment-aware import extraction. A commented-out // import foo from 'bar'; should not produce a phantom finding.
  • A pluggable rule SDK. Today rules are built in. The contract for a third-party rule is something we want to publish before adding more rules ourselves.

If you scan a project of yours and something looks wrong (a false positive, a missed defect, an unclear recommendation), please open an issue. The Community CLI is closed-source today (free, but not OSS); the feedback loop runs through GitHub issues.

Try it

curl -fsSL https://dl.shipmoor.dev/install-community-cli.sh | bash
cd path/to/your/repo
shipmoor scan --changed

If you have an agent-authored change sitting in your working tree right now, that’s the run we’d most like to hear about. What did Shipmoor catch? What did it miss? What false positive made you close the terminal? The whole point of an MVP launch is that the next version is better than this one because the people running it told us what we got wrong.

  • From the shipmoor.dev Engineering Team with love ❤️

Contact sales

Our team can help with custom support, team rollouts, and self-hosted deployments. Or to get started now, explore our self-serve plans.