jfastframework
Docs Español PyPI GitHub

Reading a project back

jfast inspect     # what is in here
jfast analyze     # what is wrong with it
jfast graph       # what depends on what
jfast check       # all of the above, plus the rest, with one exit code

check is one line of a pipeline, not the pipeline. It runs no linter, no type checker and no test suite — see What check does not check.

Three commands that answer the questions a maintainer actually asks at month six, none of which the CLI could answer before: it was very good at the first ten minutes of a project and silent afterwards.


The gap they close

jfast describe answers "what is this service" by building the app. It resolves the plugin graph, imports every plugin and reads the live settings. That answer is the authoritative one and it has two problems.

It is unavailable exactly when you want it. A missing dependency, a syntax error, a half-finished refactor — the moments you most need to know what is in front of you are the moments describe cannot run.

And it says nothing about modules. Generate two of them and neither name appears anywhere in its output:

jfast new module invoice && jfast new module order
jfast describe --json | grep -c invoice
0

The framework knew the module layout, the layer rules and the wiring, and had no way to tell you any of it. So every agent that touched a project reached for grep, and so did every person.

These three read the filesystem instead. Slightly less authoritative, always available, and they know what a module is.


jfast inspect

  shop 0.1.0 (local)

  modules (2)
    invoice  layered/api   /invoices
    order    layered/api   /orders

  plugins      observability, metrics, database
  shared       2 files
  migrations   0
  contract     contracts.toml
  frontend     -

  ✓ nothing to report

One screen. The command to run first in an unfamiliar service, and the one an agent should run before it edits anything.

jfast inspect module order    # one module in detail
jfast inspect --json          # the same, as data
jfast inspect --path ../shop  # from outside

inspect module adds the file list, the packages it imports, the tables it declares, and whether it has tests and a README.

It never imports your code. Everything above comes from parsing the source, so it works on a project whose dependencies are not installed and on one that does not currently run.


jfast analyze

Everything structurally wrong with the project, worst first, each with the fix on its own line:

  CRITICAL
    module-cycle: import cycle: invoice -> order -> invoice
      Modules in a cycle are one module with folders between them: neither can
      be extracted into a service, and a change to one breaks the other in a
      way no test covers. Keep the query in one direction, through the other
      module's public.py, and turn the other direction into an event.

  HIGH
    modules/ghost/  module-unregistered: module 'ghost' declares routes but
                    main.py never imports it
      The endpoints do not exist at runtime. Nothing fails: the tests in the
      module pass, the server starts, and the route 404s.

What it checks

Code Severity What it catches
module-cycle critical Two modules importing each other. Neither can ever be extracted.
module-unregistered high A module with routes that main.py never imports. The endpoints simply do not exist.
route-conflict high Two routers on one prefix. Whichever registers first wins; the other's paths are unreachable and FastAPI does not warn.
shared-imports-module high shared/ reaching back into a module, which turns the dependency graph into a circle.
plugin-unknown high A plugin enabled in jfast.toml that nothing provides. The app refuses to start.
contract-governs-nothing high A layer in contracts.toml whose paths match no file here. Its rules apply to nothing, and contracts check passes while enforcing nothing.
module-no-migration medium A module whose table no revision creates. Fails at the first query and nowhere earlier.
cross-module-import medium One module importing another past its public.py. An import of modules/<other>/public.py is how modules are meant to talk, and is not reported here; contracts check decides whether it is declared.
code-outside-module low A .py at the root belonging to nothing.

Why the list is short

Every check is decidable from the source text. Nothing here guesses.

That is a deliberate limit, and it costs real coverage: N+1 detection, dead code and unused dependencies are all things a fuzzier pass could report, and all things it would sometimes be wrong about. A checker that is right nine times out of ten gets muted after the second false positive — and then the true findings go with it. Ten checks nobody argues with beat thirty nobody runs.

module-no-migration is the shape of that rule in practice. It fires only once there are revisions and this module's table is in none of them. With no migrations at all the project simply has not reached that step, which is what a freshly generated one looks like, and greeting a new project with a finding is how a tool teaches people to ignore it.

Against contracts check

The contract enforces the rules you declared inside a file — which layer may import what, which calls are forbidden, what may not block. analyze reports on the shape of the project between files. Run both; they fail for different reasons and with different exit codes.

analyze does not run contracts check, and contract-governs-nothing is not an exception to that. It reads one thing out of contracts.toml: whether the layers match any file at all. Which layout the files are in and whether the contract describes it are facts about how files are arranged, decidable without opening one — this command's own question. What a layer permits inside a file stays contracts check's, and re-emitting its findings here would give one report two owners, two severities and two remedies, which is how two commands drift into contradicting each other.

Staying silent instead was the worse option, and it is what this replaces. On a service scaffolded with the layered contract and then filled with hexagonal modules, contracts check failed with three layer-unmatched violations, jfast next reported contracts check fails (3 violations) — and jfast analyze printed ✓ no findings about the same directory. The finding is produced by calling check_coverage, the checker's own function, so the two commands now report the same count and the same sentences.

jfast analyze --fail-on critical   # only the worst
jfast analyze --fail-on never      # report, never fail
jfast analyze --json               # counts and findings, as data

Default is --fail-on high.


jfast graph

  invoice
    └─ (no module dependencies)
  order
    └─ invoice

jfast workspace graph draws services. This draws the modules inside one, which is the graph that decides whether a module can ever be extracted: a module nothing imports is a service waiting to happen, and a cycle is two modules that will never be either.

jfast graph --module billing        # only its edges
jfast graph --format mermaid        # paste into a README
jfast graph --format dot            # pipe to graphviz
jfast graph --format json

jfast check

Every static check this framework has, in one command and one exit code.

Every check on this page already existed. What did not exist was a single thing to run, so CI ran three of them, an agent ran whichever one it remembered, and the two nobody wired up never ran at all.

It does not run your linter, your type checker or your tests, and every run says so in its last two lines. Read What check does not check before you replace anything with it.

  shop

  ✓ config      pass   shop (local)
  ✓ plugins     pass   3 enabled, 20 installed
  ✗ analyze     fail   high 1
  ✗ contracts   fail   high 1
  ✓ migrations  pass   0 revisions read, no database
  ✓ deploy      pass   compose renders, 2 services

  ANALYZE  (validation failure, exit 1)
  HIGH
    modules/order/  module-unregistered: module 'order' declares routes but
                    main.py never imports it
      The endpoints do not exist at runtime. Nothing fails: the tests in the
      module pass, the server starts, and the route 404s.

  CONTRACTS  (contract violation, exit 5)
  HIGH
    modules/invoice/service.py:43  forbid-call: print() is not allowed here
      Use the structured logger; print output has no request id and no level.

  4 pass, 2 fail in 0.13s
  exit 5  (contract violation)

  not checked here: lint, formatting, types, tests
  ruff check .  ruff format --check .  mypy .  pytest
jfast check                          # everything, human output
jfast check --json                   # everything, as data
jfast check --ci                     # strict: any finding fails, and so does any skip
jfast check --only contracts,analyze # a subset
jfast check --fail-on critical       # same threshold flag as `jfast analyze`
jfast check --multitenant-ready      # instead of the battery: what a switch to multitenant breaks
jfast check --path ../shop           # from outside

Against doctor and workspace validate

doctor asks a question about your machine: does the configuration resolve, does every enabled plugin import on this interpreter. Its answer changes when you switch virtualenv and never when you change a line of code.

check asks a question about the repository: is what is committed here consistent with itself. It runs doctor's two questions as its first two checks, because a broken install makes every downstream answer a lie — but doctor stays, because when you are debugging your own laptop you want the two-second answer and not the whole battery.

jfast workspace validate reads one file, the workspace resource graph, and is scoped to a workspace rather than to a service. check runs it as part of deploy.

Only check belongs in CI.

The seven checks

Each one is named after the command that already ran it. There is no second vocabulary, and the name is what --only takes.

Check What it runs Exit code on failure
config JFastConfig.load — the same load doctor does 2
plugins registry.discover(), registry.build(), and the unimportable list 3
analyze jfast analyze — structure between files 1
tenancy tenant settings that contradict each other or the code (multitenancy) 1
contracts jfast contracts check — the rules you declared 5
migrations jfast migration check, statically: no database 4
deploy workspace.validate() plus rendering docker-compose 1

Nothing here starts a container, opens a socket or talks to a database. That is what makes the command safe in a pre-commit hook, and it is also its limit: deploy proves the compose file can be generated and is internally consistent, not that the images pull.

Notices: the environment overrides jfast.toml

One thing check reports without ever failing on it. For a setting the environment owns (Which wins), a variable set in this process that disagrees with jfast.toml is listed under config, with file, line and variable, credentials masked:

  ✓ config      pass   shop (local)  (1 environment override)
  ...
  CONFIG  the environment overrides jfast.toml (reported, never a failure)
    jfast.toml:52  [plugin.mail] backend = 'console' is overridden by JFAST_MAIL_BACKEND='smtp' from the environment

In --json they are the check's notices (severity medium, code environment-overrides-file) and the top-level notices count. They never set a status or an exit code, --ci included: every other answer here is the same on every machine, and this one is about the machine -- run with the deployment's environment, it lists exactly what the boot WARNING lists; on a laptop, the laptop's. A file default the deployment replaces is usually the design working, and failing on it would push a team to delete the default or copy production's values into CI.

What check does not check

jfast check runs no linter, no formatter, no type checker and no test suite. Nothing in its output changes when any of them fail:

# an unused import, a misformatted file, a str assigned to an int,
# and a failing test, all present at once
ruff check .            # 17 errors
ruff format --check .   # 4 files would be reformatted
mypy .                  # 2 errors
pytest                  # 1 failed

jfast check             # 6 pass — byte-identical to the clean project
jfast check --ci        # exit 0

That is not a bug to be fixed by running them. pytest executes your code for an unbounded time against whatever a fixture decides to start, and mypy in a tree whose dependencies are not installed reports missing imports that your own configuration would have silenced — either one inside this command turns a pre-commit hook into a build, and a checker that manufactures findings is muted within a week. check answers whether the repository is consistent with itself; those four answer whether the code is correct. Two questions, two commands.

So the cost is paid in the open. Every run ends with the four it left to you, pass or fail:

  not checked here: lint, formatting, types, tests
  ruff check .  ruff format --check .  mypy .  pytest

and --json carries the same under not_covered, because a script reading "ok": true cannot read a footer:

{
  "ok": true,
  "not_covered": [
    {"what": "lint", "catches": "unused imports, undefined names, unreachable code",
     "command": "ruff check ."},
    {"what": "formatting", "catches": "a diff nobody agreed to review",
     "command": "ruff format --check ."},
    {"what": "types", "catches": "a str where an int was declared", "command": "mypy ."},
    {"what": "tests", "catches": "whether any of it works", "command": "pytest"}
  ]
}

The line to put in CI is the whole line:

jfast check --ci && ruff check . && ruff format --check . && mypy . && pytest

One exit code from many checks

Six checks, one number. When several fail, the code is taken from the failure that invalidates the most of the report — not from the worst severity:

2 config  >  3 plugins  >  4 migrations  >  5 contracts  >  1 analyze/deploy

A jfast.toml that does not parse makes every other answer a guess, so it wins. A plugin that will not import is next, for the same reason. Migrations beat contracts because a schema the code does not agree with fails in production at the first query and is the one result you must not ship past; a contract violation, by contrast, is visible in the diff and fails safely. Contract beats analyze because a contract is a rule somebody wrote down, while analyze findings are the broadest and least specific class — last, so any more informative code wins over it.

The single number is a summary, never the whole answer. --json carries codes — every failing check's code — so a script never has to infer the other five:

{
  "ok": false,
  "exit_code": 5,
  "exit_meaning": "contract violation",
  "codes": [1, 5],
  "failed": ["analyze", "contracts"],
  "skipped": [],
  "complete": true
}

A skip is not a pass

A check that could not run reports as skip, with the reason, in both output modes:

  · contracts   skip   no contracts.toml in /tmp/shop (jfast contracts init)

  4 pass, 1 fail, 1 skip in 0.07s
  · skipped: contracts -- not a pass

In --json it is status: "skip" with a non-null reason, its name is in skipped and not in passed, and complete is false. A battery that quietly reports green on a check it did not run is worse than no battery: it converts "I did not look" into "I looked and it was fine".

Under --ci, a skip fails, with exit 3 — environment error. That is what a skip in CI means: the runner was missing something the check needed, and the pipeline is not checking what you think it is checking. Locally a skip is normal and informative; in CI it is a hole.

--ci --allow-skips is the one way past it, and it is deliberately explicit — a team that genuinely cannot supply something writes the flag into the pipeline where a reviewer can see it, rather than never noticing the hole.

A skip only ever decides the exit code when nothing else failed. A run that both violates its contract and skipped a check exits 5, not 3: there is a concrete defect to report, and blaming the runner for it would be wrong.

What it costs

On a freshly generated service the checks themselves take 120–185 ms, and the whole process 0.65–0.9 s wall clock — nearly all of the difference is Python's own startup and imports, which a pre-commit hook pays for any tool.

Measured per check, on that same service:

Check Cost
config ~1 ms
migrations ~3–12 ms
contracts ~30 ms
plugins ~45–85 ms
analyze ~60–100 ms (includes plugins)
deploy ~60–90 ms (includes plugins)

plugins is the slow one and it dominates the rest: registry.discover() imports every installed plugin. It runs once per invocation and the result is handed to analyze — which needs the installed set to report plugin-unknown — and to deploy, rather than being recomputed. Everything else is AST parsing, which scales with the number of .py files and not with the size of the project's dependencies.

--only contracts,migrations is the subset that avoids plugin discovery entirely, at ~35–45 ms. That is the shape to put in a pre-commit hook if the full run ever stops feeling instant — with the full battery still in CI.

One caveat on migrations: with no database it reads every revision, not just the pending ones — which are applied is a fact only the database has — and treats every table as populated, which is the conservative reading. On a repository with a long migration history, jfast migration check --dsn ... is the narrower answer, and --only can drop this check from the battery.


Exit codes

Standard across the whole CLI, documented and tested. A script that has to read English to find out why a command failed is a script that breaks when the wording changes.

Code Meaning
0 success
1 validation failure — a check found something wrong
2 configuration error — a file is missing or does not parse
3 environment error — Docker, a container, a database
4 migration risk or failure
5 contract violation
6 user input error
7 compatibility or version error

Telling 1 from 5 is what lets CI say why it failed without parsing text:

jfast analyze || case $? in
  1) echo "structure" ;;
  2) echo "config" ;;
esac

These are part of the public API. A code never changes meaning; a new failure mode gets a new number.


For an agent

jfast inspect --json          # what exists
jfast graph --format json     # what depends on what
jfast analyze --json          # what is wrong, with the fix in `why`
jfast contracts show --json   # the rules
jfast contracts check --json  # the violations
jfast check --json            # every static check, with one exit code

jfast check --json is the last jfast call to make before reporting a change as finished: it is the only one that fails when something was never examined, so "it passed" cannot mean "it did not run". Read skipped and complete before reading ok — and read not_covered, which names the linter, the formatter, the type checker and the test suite it did not run. "ok": true is not "the change is done"; it is "the repository is consistent with itself".

Five commands, no source reading, no guessing at conventions. The why field on every finding is the part that matters: an agent handed a violation with no remedy tends to satisfy the checker rather than fix the design.