Code quality
FoBiS reads your Fortran the way your build compiles it and answers from that reading: fobis lint reports bug-prone, obsolescent and non-portable constructs and parallel-code defects (data races in OpenMP regions, missing reduction clauses, device calls without !$acc routine, MPI requests never completed, collectives in rank branches); fobis review reports them on the lines a change touches; fobis query says who calls a procedure and who writes a variable; fobis modernize applies the mechanical fixes and keeps them only when the tests and cases say nothing changed; fobis bindings writes the C and Python interface of your modules.
How it works:
- Statements, not a parser. Each file is cut into logical statements (continuation lines joined, comments dropped,
;split, labels detached, OpenMP/OpenACC directives kept as statements). Fixed form is chosen by the extension (.f,.for,.fpp,.f77,.fortran, in either case) and ignores the columns beyond 72, as the compilers do by default. A statement the analysis does not recognise is skipped, and the rest of the file is still analysed. Macros used inside Fortran code (a type spelledVARTYPE(KKP)) are not expanded; the declarations written with them are still recognised. - The mode's defines.
#if/#ifdefare resolved with the-Dflags of the selected mode (itscflags,preprocandfeatures), so--mode mpianalyses the#ifdef USE_MPIbranch and not the#else. A mode withopenmp = Truemakes the!$conditional lines code. Without a fobos, no macro is defined. A condition beyond the evaluator (a function-like macro) is assumed true and reported asLNT004. - The files. The paths given on the command line, else the mode's
src(default./) with itsexcludeandexclude_dirs. Fetched dependencies (.fobis_deps/) and VCS directories are always skipped: you lint your code, not your dependencies'. C sources are never analysed; a.hfile holding C (/*,typedef,extern,#include <...>) is skipped and counted in the summary. - A project index. Every file is analysed before any rule runs, so a rule uses facts from other files: a procedure called inside an OpenACC compute region is checked for
!$acc routinewhere it is defined, a kind parameter is resolved where it is declared, a branch is rank-dependent when its variable is filled bympi_comm_rankanywhere in the project. Lint the project, not one file, to get these checks.
Tutorial: the linter and the rest of track 4. Reference: lint, review, query, modernize, bindings. Rules: lint rules.
Lint a project
fobis lint # the sources of the default mode, with its defines
fobis lint --mode mpi # the code as that mode compiles it
fobis lint src/solver.F90 src/io/ # files or directories
fobis lint --src lib/ # the sources to scan when there is no fobos (repeatable)
fobis lint --explain COR002 # why a rule exists, and what to write instead$ fobis lint
src/integrate_mpi.F90:4: COR040 [warning] 'use mpi' has no 'only:' list
src/quad_rules.F90:10: COR002 [warning] interface body function 'integrand' has no 'implicit none'
src/romberg.f:3: COR001 [warning] function 'romb' has no 'implicit none'
src/romberg.f:3: COR031 [warning] function 'romb' is not in a module
src/romberg.f:4: COR010 [warning] dummy argument 'a' of function 'romb' has no intent
src/romberg.f:4: COR010 [warning] dummy argument 'b' of function 'romb' has no intent
src/romberg.f:4: COR010 [warning] dummy argument 'tol' of function 'romb' has no intent
src/romberg.f:4: COR030 [warning] 'f' is declared external in function 'romb'
src/romberg.f:11: OBS011 [warning] labelled do loop (obsolescent): use do ... end do
src/romberg.f:14: OBS011 [warning] labelled do loop (obsolescent): use do ... end do
src/romberg.f:19: OBS011 [warning] labelled do loop (obsolescent): use do ... end do
src/romberg.f:23: MDN021 [warning] old relational operator .lt.: use symbolic operators
src/romberg.f:23: OBS005 [warning] specific intrinsic name 'dabs' (obsolescent): use the generic name
test/test_romberg.F90:7: COR030 [warning] 'romb' is declared external in program 'test_romberg'
14 finding(s) in 10 file(s): COR001 x1, COR002 x1, COR010 x3, COR030 x2, COR031 x1, COR040 x1, MDN021 x1, OBS005 x1, OBS011 x3
Explain a rule with: fobis lint --explain <ID>
[exit status 1]Every finding names its rule; --explain prints the rule's documentation, its Fortitude equivalent and the knowledge-pack sections it rests on:
$ fobis lint --explain COR002
COR002 interface-implicit-typing [warning]
An interface body has no `implicit none`.
An interface body is a separate scoping unit: it does not inherit the `implicit none` of the module
or procedure that contains the interface block. Dummy arguments left undeclared in the body are
typed implicitly, and the interface then disagrees with the procedure it describes.
Reported:
interface
subroutine callback(x)
real :: x
end subroutine callback
end interface
Preferred:
interface
subroutine callback(x)
implicit none
real, intent(inout) :: x
end subroutine callback
end interface
Equivalent Fortitude rule: C002
References (read with: fobis kb show <id>):
fortran-std:ch19-scope-association#anti-patterns
Suppress one occurrence with: ! fobis: ignore[COR002] <reason>- Exit status 1 when a finding of severity warning or error remains, or a file cannot be read;
infofindings never fail.--exit-zeroalways exits 0. An unknown rule id or format, or a malformed baseline, exits 1. --varset NAMEapplies the[varset:NAME]bindings first, as for a build.- Formatting is out of scope (line length, spacing, keyword case): leave it to a formatter such as fprettify. The
STYrules check structure only.
Choose the rules
The rules marked default in the rule reference run unless you choose: 74 of the 105. Every default rule was checked for false alarms against a corpus of 19 open-source Fortran repositories; the heuristics and the matters of project policy ship opt-in. An explicit selection, by id or prefix, also turns on the opt-in rules it matches:
fobis lint --select OBS,MDN # comma-separated, or repeat --select
fobis lint --select ALL --ignore COR040 # everything except use-without-only
fobis lint --select COR01 # a prefix: COR010 to COR019
fobis lint --list-rules # id, name, severity, default or opt-in, Fortitude equivalent$ fobis lint --select OBS,MDN
src/integrate_mpi.F90:4: MDN201 [warning] use mpi: prefer use mpi_f08
src/romberg.f:4: MDN001 [warning] double precision declaration: use real with a named kind parameter
src/romberg.f:6: MDN001 [warning] double precision declaration: use real with a named kind parameter
src/romberg.f:9: MDN002 [warning] double precision literal: use a kind suffix (1.0_rk)
src/romberg.f:11: OBS011 [warning] labelled do loop (obsolescent): use do ... end do
src/romberg.f:12: MDN002 [warning] double precision literal: use a kind suffix (1.0_rk)
src/romberg.f:13: MDN002 [warning] double precision literal: use a kind suffix (1.0_rk)
src/romberg.f:14: OBS011 [warning] labelled do loop (obsolescent): use do ... end do
src/romberg.f:17: MDN002 [warning] double precision literal: use a kind suffix (1.0_rk)
src/romberg.f:19: OBS011 [warning] labelled do loop (obsolescent): use do ... end do
src/romberg.f:20: MDN002 [warning] double precision literal: use a kind suffix (1.0_rk)
src/romberg.f:23: MDN021 [warning] old relational operator .lt.: use symbolic operators
src/romberg.f:23: OBS005 [warning] specific intrinsic name 'dabs' (obsolescent): use the generic name
13 finding(s) in 10 file(s): MDN001 x2, MDN002 x5, MDN021 x1, MDN201 x1, OBS005 x1, OBS011 x3
Explain a rule with: fobis lint --explain <ID>
[exit status 1]The families: COR correctness, OBS obsolescent and deleted features, MDN modernisation, PRT portability, STY structure, FOR Fortran practice for HPC, OMP OpenMP, ACC OpenACC, MPI MPI, NUM numerics, PRF performance reported by the compiler, LNT the linter's own suppressions and assumptions. The generic rules reimplement, natively, rules catalogued by Fortitude; each rule page names its Fortitude code.
A project fixes its choice once, for every developer and the CI, in a [lint] section:
[lint]
; rule ids or prefixes, comma- or space-separated; ALL for every rule
select = COR, OBS, OMP
; rule ids or prefixes skipped
ignore = COR040, OBS011
; findings recorded here are not reported
baseline = .fobis-lint-baseline.json- Precedence:
--selectreplacesselect,--ignoreadds toignore,--baselinereplacesbaseline. - One section for all modes. The sources, exclusions and defines come from the mode (
--mode); the rules come from[lint].fobis reviewreads the same section.
Lint parallel code
The FOR, OMP, ACC and MPI rules analyse the directive regions of each procedure: the statements between !$omp parallel and !$omp end parallel, the loop nest after !$acc parallel loop, the statement after !$omp atomic.
fobis lint --mode openmp --select OMP,ACC,MPI,FOR # the parallel rules, opt-in ones included
fobis lint --select FOR002,OMP004,MPI005 # opt-in heuristics, one by one- Data sharing follows the Fortran rules. Loop variables of the region's DO loops are private;
private,firstprivate,lastprivate,reduction,linearandthreadprivateare honoured; writes insidecritical,atomic,single,master/masked,ordered, or guarded byomp_get_thread_num() ==, are not reported. A shared variable is reported once per region, at its first write; a variable assigned and then updated in one iteration is a shared temporary (OMP001), not a missing reduction (OMP002). - Regions stop at procedure boundaries. A procedure called from a parallel region is checked for saved locals (
FOR001), not for writes to the caller's shared variables. Procedures are matched across files by name, without followinguserenames. - Data flow is source order within one procedure, loops not unrolled:
OMP004(reads afternowait, up to the next barrier),MPI002/MPI003(a request is pending from its non-blocking call to the next statement using the request; whole-array buffers only: withbuf(k)the message covers part ofbuf),ACC009(updates in the same procedure). - Rank branches.
MPI004/MPI005know a branch is rank-dependent when its condition uses a variable thatmpi_comm_rankfills somewhere (myrank,self%rank). A construct whose branches call the same collectives in the same order is not reported. - Kinds are resolved through the integer constants of the linted sources (
R8P = selected_real_kind(15, 307),rk = real64,kind(1.d0)); a kind module outside them is unknown, except PENF'sR4P/R8P/R16P.FOR007reports only literals a single-precision real cannot represent (0.1, not0.5). - GPU code is not CPU code:
FOR002(loop order) skips OpenACC compute regions, OpenMPtargetregions, device routines and CUDA Fortrandevicearrays, where the parallel index leads on purpose. - Opt-in, because they are heuristics or matter in some codes only:
FOR002,FOR003,NUM001,OMP004,OMP006,ACC007,ACC009,ACC010,MPI005,MPI006. Select them when reviewing parallel code.
Adopt the linter on old code
The first run on a large code reports hundreds of findings. Record them as the accepted state; from then on only new findings are reported, and the linter can run in CI today:
fobis lint --update-baseline # record the current findings; commit .fobis-lint-baseline.json
fobis lint # only findings not in the baseline$ fobis lint --update-baseline && head -n 14 .fobis-lint-baseline.json
Baseline .fobis-lint-baseline.json: 12 finding(s) recorded
{
"version": 1,
"tool": "fobis lint",
"findings": [
{
"rule": "COR001",
"path": "src/romberg.f",
"fingerprint": "9edb8cdb21af9611",
"message": "function 'romb' has no 'implicit none'"
},
{
"rule": "COR031",
"path": "src/romberg.f",
"fingerprint": "a34062bfd0a685a4",$ fobis lint
0 finding(s) in 10 file(s), 12 hidden by the baseline- A finding is recorded by rule, file and the normalised text of its statement, not by line: code added above it does not bring it back; editing the statement, or renaming the file, does.
- Counts matter. Each record hides one occurrence: a second identical statement in the same file is a new finding.
--update-baselinerecords what the current selection reports, and overwrites the file. Run it with the selection the CI uses (the[lint]section), and again after fixing findings, to shrink the file.- The file:
--baseline FILE, elsebaselinein[lint], else.fobis-lint-baseline.json, read only when it exists (paths relative to the current directory). fobis reviewuses no baseline: it reports the changed lines only, so an old code stays quiet without one.
Tutorial: suppressions, baselines and fixes.
Silence one finding, with a reason
When a finding is wrong for a reason the linter cannot see, say so in the code, on the reported statement (any of its lines) or alone on the line before it:
use mpi ! fobis: ignore[COR040] the mpi module's names are all prefixed mpi_
! fobis: ignore[COR011, COR010] C interface: the buffer size is passed separately
subroutine fill(buf, n) bind(c)$ fobis lint --select COR002,COR040
0 finding(s) in 10 file(s)The linter checks its own suppressions: one without a reason is LNT001, one that silences nothing is LNT002 (it would hide a future finding on that line), a misspelled rule id is LNT003. A suppression of a rule that is not selected is not reported as unused.
Fix what can be fixed automatically
Five rules carry a mechanical fix:
| Rule | Rewrite |
|---|---|
MDN011 | (/ ... /) -> [ ... ] |
MDN021 | .eq. .ne. .lt. .le. .gt. .ge. -> == /= < <= > >= |
OBS005 | specific intrinsics -> generic (dsqrt -> sqrt, alog -> log, dmax1 -> max, float -> real, idint -> int ...); not amax0, amin0, max1, min1, which convert the result's type |
OBS008 | character*n, character*(*) -> character(len=n), character(len=*) |
OBS011 | do 10 ... / 10 continue -> do ... / end do, only when that loop alone ends on a continue and nothing (go to, arithmetic if, err=/end=/eor=, assign, alternate return) refers to the label |
fobis lint --select OBS005,OBS011,MDN021 --diff # a unified diff; nothing changes
fobis lint --select OBS005,OBS011,MDN021 --fix # rewrite the files, then report what remains$ fobis lint --select OBS005,OBS011,MDN021 --diff
--- a/src/romberg.f
+++ b/src/romberg.f
@@ -8,20 +8,20 @@
H = B - A
R(1,1) = 0.5D0*H*(F(A)+F(B))
N = 1
- DO 30 I = 2, 10
+ DO I = 2, 10
H = 0.5D0*H
S = 0.0D0
- DO 10 K = 1, N
+ DO K = 1, N
S = S + F(A+DBLE(2*K-1)*H)
- 10 CONTINUE
+ END DO
R(I,1) = 0.5D0*R(I-1,1) + H*S
N = 2*N
- DO 20 J = 2, I
+ DO J = 2, I
R(I,J) = R(I,J-1)
& + (R(I,J-1)-R(I-1,J-1))/(4.0D0**(J-1)-1.0D0)
- 20 CONTINUE
- IF (DABS(R(I,I)-R(I-1,I-1)) .LT. TOL) GOTO 40
- 30 CONTINUE
+ END DO
+ IF (ABS(R(I,I)-R(I-1,I-1)) < TOL) GOTO 40
+ END DO
I = 10
40 ROMB = R(I,I)
RETURN- Code only. Comments and character literals are left alone; the case of the code is kept (
DSQRT->SQRT,END DO); fixed form works, withend doat the column of itsdo. - Only the reported findings are fixed: the selection applies, and suppressed or baselined findings are not touched. When two edits overlap, the second is skipped: run
fobis lintagain after--fix. --diffexits 0;--fixexits as a lint run on what remains.- A fix that compiles can still change results. To keep the fixes only when the tests and cases agree, see Modernise legacy code, verified.
Lint in CI
fobis lint --format github # annotations on the pull request's lines
fobis lint --format sarif --exit-zero > lint.sarif # GitHub code scanning, IDE viewers
fobis lint --format json # scripts$ fobis lint --format github --select COR040
::warning file=src/integrate_mpi.F90,line=4,endLine=4,title=COR040 use-all::'use mpi' has no 'only:' list
[exit status 1]- run: pip install FoBiS.py
- run: fobis lint --format github
# or: findings tracked across commits in the repository's Security tab
- run: fobis lint --format sarif --exit-zero > fobis-lint.sarif
- uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: fobis-lint.sarifjsonholdsfiles,baselined,unreadableand the findings (rule, name, severity, path, line, end_line, message, fingerprint).sarifis SARIF 2.1.0; each result carries the line-independent fingerprint, so code scanning follows a finding across commits, and each rule links its page of the rule reference.githubmaps the severities toerror,warningandnotice.
More in Continuous integration.
Compiler feedback
The compiler knows what the source does not show: which arrays an OpenACC region copies at every launch, which device loop it ran sequentially, which OpenMP target maps it generated, which loops it did not vectorise. --compiler-feedback reads its optimisation report from a build log and reports it on your source lines, next to the other rules:
# 1. a build with the report flags, apart from your build tree (one job: messages must not interleave)
fobis build --mode nv --cflags "-Minfo=accel,mp" --build-dir .fobis/feedback -j 1 --force-compile --no-cache \
2>&1 | tee build.log
# 2. lint with the report, in the same mode
fobis lint --mode nv --compiler-feedback build.logOn the genuine nvfortran 26.5 log of FoBiS's test fixture (tests/compiler-feedback/):
$ fobis lint --mode nv --compiler-feedback build.log
src/kernels.f90:1: COR050 [warning] module 'k' has no default accessibility statement
src/kernels.f90:9: ACC001 [warning] implicit data movement for the compute region: copyin(x(:n)), copy(y(:n))
src/kernels.f90:18: ACC001 [warning] implicit data movement for the compute region: copyout(y(2:n)), copyin(y(:n))
src/kernels.f90:19: ACC012 [warning] loop runs sequentially on the device: loop-carried dependence of y
src/kernels.f90:29: OMP008 [warning] implicit map(tofrom:y(:)) for the target region
src/kernels.f90:31: OMP001 [warning] 't' is shared and written by every thread (missing private?)
6 finding(s) in 2 file(s): ACC001 x2, ACC012 x1, COR050 x1, OMP001 x1, OMP008 x1
Compiler feedback: 18 message(s) read
Explain a rule with: fobis lint --explain <ID>| Compiler | Report flags | Rules fed |
|---|---|---|
| nvfortran | -Minfo=accel,mp | ACC001 implicit data movement, ACC012 sequential device loop, OMP008 implicit target map |
| gfortran | -fopt-info-vec-missed | PRF001 loop not vectorized (opt-in, severity info: --select PRF001) |
- FoBiS does not run the feedback build. When the log is missing,
fobis lintexits 1 and prints the command for the mode's compiler, as above. An explicit--build-dirwins over the mode'sbuild_dir, so the feedback objects stay out of your build tree. - Attribution, never a guess. nvfortran names procedures, not files: a message is attributed through the project's procedures (a name defined in several files through the log's
Compiling ...lines); what cannot be attributed is counted in the summary. - The findings describe the build of the log: lint with the same
--mode.-reads the log from standard input.fobis reviewtakes--compiler-feedbacktoo.
Ask a model about the findings
The rules say "this pattern is here", not "this is a bug in your code". --deep asks a language model to judge each finding against its code, with citations; --ask answers a question about the code at a line:
fobis lint src/solver --select OMP,ACC,MPI --deep # a cited verdict per finding, region by region
fobis lint --deep --deep-max 25 # more regions (default 10)
fobis lint --deep-context # print the model's input, run no model
fobis lint src/flux.F90:120 --ask "is this region race-free?"Nothing is hidden: a dismissed finding is still printed, with the model's reason, and still counts in the exit status. The verdicts annotate the text and json formats. Setting up a model, and how far to trust a 7B model's verdicts, in AI assistance.
Review only what changed
fobis review lints the whole project (the cross-file rules need every file) with the same mode, [lint] section, rules and --compiler-feedback as fobis lint, then keeps the findings whose lines touch an added or modified line. A legacy code with hundreds of findings stays quiet, and a new finding shows the moment it is introduced.
fobis review # HEAD against the working tree: staged and unstaged changes
fobis review --staged # the index against HEAD: a pre-commit hook
fobis review --base origin/master # the commits since the merge base: a pull request
fobis review --base origin/master --worktree # the merge base against the working tree
fobis review --context 3 # also findings up to 3 lines away from a changed lineA change that moves the number of intervals into a variable:
$ git diff
diff --git a/src/sweep.F90 b/src/sweep.F90
index 7d85740..c5396f5 100644
--- a/src/sweep.F90
+++ b/src/sweep.F90
@@ -9,11 +9,13 @@ integer, parameter :: cases = 12 !< Number of cases.
real(R8P), parameter :: pi = acos(-1._R8P) !< Pi.
real(R8P) :: error(2, cases) !< Errors, trapezoid and Simpson, of each case.
integer :: k !< Counter.
+integer :: n !< Number of intervals.
!$omp parallel do
do k = 1, cases
- error(1, k) = abs(trapezoid(sine, 0._R8P, pi, 2 ** k) - 2._R8P)
- error(2, k) = abs(simpson(sine, 0._R8P, pi, 2 ** k) - 2._R8P)
+ n = 2 ** k
+ error(1, k) = abs(trapezoid(sine, 0._R8P, pi, n) - 2._R8P)
+ error(2, k) = abs(simpson(sine, 0._R8P, pi, n) - 2._R8P)
enddo
!$ print '(A, I0)', 'threads: ', omp_get_max_threads()
print '(A6, 2A18)', 'n', 'trapezoid error', 'simpson error'$ fobis review
src/sweep.F90:16: OMP001 [warning] 'n' is shared and written by every thread (missing private?)
1 finding(s) in 10 file(s): OMP001 x1
Explain a rule with: fobis lint --explain <ID>
Reviewed 4 changed line(s) in 1 Fortran file(s).
[exit status 1]The fix is !$omp parallel do private(n); then:
$ fobis review
0 finding(s) in 10 file(s)
Reviewed 5 changed line(s) in 1 Fortran file(s).- A changed directive counts its whole region as changed. Removing
private(flux) reduction(+:total)changes one line, yet the race onfluxand the unprotected sum ontotalare on the lines below: the OpenMP/OpenACC region of a changed directive is reviewed as a whole. - New files must be known to git. The change is what
git diffshows: an untracked file is not reviewed until it is staged (or marked withgit add -N). - Line numbers. With
--stagedor--basethe reviewed side is the index orHEAD, while the linter reads the files on disk: a reviewed file with other uncommitted changes gets a warning, its lines may not match. - Exit status 1 when a warning or error is on the changed lines, a file cannot be read, the directory is not a git repository or the base is unknown;
--exit-zeroreports only. - What it cannot see: a finding caused by the change but located elsewhere (a caller of a procedure whose interface changed, a module variable now shared). Lint the whole code from time to time, or widen with
--context. - Fortran sources only; other changed files reach the
--deepsummary only.
Tutorial: reviewing a change.
Review in a pre-commit hook and in pull requests
A pre-commit hook runs the rules only, no model: it takes as long as fobis lint on the project. With pre-commit, in .pre-commit-config.yaml:
repos:
- repo: local
hooks:
- id: fobis-review
name: fobis review
entry: fobis review --staged
language: system
pass_filenames: false
files: '\.(f|F|for|f90|F90|f95|f03|f08)$'Or a plain .git/hooks/pre-commit, executable:
#!/bin/sh
exec fobis review --stagedFor a pull request, the findings as a comment:
$ fobis review --format markdown
### fobis review
1 finding(s) on the changed lines (1 warning), 4 changed line(s) in 1 Fortran file(s).
| Severity | Rule | Location | Message |
|---|---|---|---|
| warning | `OMP001` shared-scalar-written-in-parallel-region | `src/sweep.F90:16` | 'n' is shared and written by every thread (missing private?) |
<details><summary>Rules</summary>
- `OMP001` shared-scalar-written-in-parallel-region: A shared scalar is assigned inside an OpenMP parallel region: a data race.
Explain a rule with `fobis lint --explain <ID>`.
</details>
[exit status 1]- uses: actions/checkout@v4
with:
fetch-depth: 0 # the merge base must be in the clone
- run: pip install FoBiS.py
- name: Review the change
run: |
fobis review --base origin/${{ github.base_ref }} --format github
fobis review --base origin/${{ github.base_ref }} --format markdown --exit-zero >> "$GITHUB_STEP_SUMMARY"The first command annotates the changed lines and fails the job on a finding; the second writes the report to the job summary. Formats: text, markdown, github, sarif, json (the findings plus changed_lines). fobis gate --base REF runs this review with --worktree among its stages: see Verifying results.
A risk summary of the change
--deep adds a summary written by a language model from facts: the diff of each changed Fortran file, the changed code in context, the OpenMP/OpenACC regions the change touches, the files that use a module whose procedure headers changed, the findings on the changed lines and their rules. The answer is cited claims of two kinds, risks and to check; a claim citing nothing is dropped.

coder-7b on an NVIDIA A30 of a cluster node, reached through ssh; recorded in real time.
fobis review --deep # findings, then the risk summary
fobis review --deep-context # the model's input; no model runs
fobis review --base origin/master --deep --format markdownThe summary adds to the text, markdown and json formats (risk in JSON); with github or sarif it is skipped with a warning. It never changes the exit status, and an unavailable model leaves the review without it. Models and their limits: AI assistance.
Who calls a procedure, who writes a variable
fobis query answers from the same analysis, not from a text search: a call on a continuation line, a statement after a ;, a module variable renamed on use, a type-bound call on an array element are found; comments, strings and inactive preprocessor branches are not.
fobis query callers simpson # call sites: call, function references, obj%run(), objs(i)%run()
fobis query callees solver::step # what it calls, and how each name was resolved
fobis query uses quad_rules # the units using a module, with their only-lists
fobis query writers solver::residual # assignments, read, allocate, intent(out/inout) actual arguments
fobis query readers residual # every other mention, intent(in/inout) actual arguments
fobis query defines integrand # modules, procedures, generics, types, bindings, variables, parameters
fobis query interface trapezoid # header and dummies; a generic's specifics; the types binding a name
fobis query region src/sweep.F90:17 # the directive regions holding the line, with their clauses
fobis query device rho # data directives naming it, compute constructs using it unnamed
fobis query mpi collective # MPI calls: point-to-point, collective, one-sided, io, setup$ fobis query callers simpson
callers simpson: 6 result(s)
src/integrate.F90:14: integrate [function, unique] -> quad_rules::simpson: print '(I6, 2ES18.4)', n, abs(trapezoid(sine, 0._R8P, pi, n) - 2._R8P), abs(simpson(sine, 0._R8P,...
src/integrate.F90:17: integrate [function, unique] -> quad_rules::simpson: print '(A)', 'Simpson, 64 intervals: '//trim(str(simpson(sine, 0._R8P, pi, 64), no_sign=.true., c...
src/integrate_mpi.F90:20: integrate_mpi [function, unique] -> quad_rules::simpson: piece = simpson(sine, a, b, n)
src/sweep.F90:18: sweep [function, unique] -> quad_rules::simpson: error(2, k) = abs(simpson(sine, 0._R8P, pi, n) - 2._R8P)
test/test_order.F90:12: test_order [function, unique] -> quad_rules::simpson: p_simpson = log(abs(simpson(sine, 0._R8P, pi, 16) - 2._R8P) / abs(simpson(sine, 0._R8P, pi, 32) -...
test/test_order.F90:12: test_order [function, unique] -> quad_rules::simpson: p_simpson = log(abs(simpson(sine, 0._R8P, pi, 16) - 2._R8P) / abs(simpson(sine, 0._R8P, pi, 32) -...
(10 file(s) analysed)$ fobis query interface trapezoid
interface trapezoid: 1 result(s)
src/quad_rules.F90:38: quad_rules::trapezoid
pure function trapezoid(f, a, b, n)
procedure(integrand) :: f
real(r8p), intent(in) :: a
real(r8p), intent(in) :: b
integer, intent(in) :: n
result: real(r8p) :: s
(10 file(s) analysed)$ fobis query writers error
writers error: 2 result(s)
src/sweep.F90:17: sweep [local; assignment]: error(1, k) = abs(trapezoid(sine, 0._R8P, pi, n) - 2._R8P)
src/sweep.F90:18: sweep [local; assignment]: error(2, k) = abs(simpson(sine, 0._R8P, pi, n) - 2._R8P)
note: pointer aliasing and accesses under another name (argument association) are not followed
(10 file(s) analysed)$ fobis query region src/sweep.F90:17
region src/sweep.F90:17: 1 result(s)
src/sweep.F90:14-19: sweep: !$omp parallel do private(n)
(10 file(s) analysed)$ fobis query mpi
mpi: 5 result(s)
src/integrate_mpi.F90:15: mpi_init [setup] in integrate_mpi
src/integrate_mpi.F90:16: mpi_comm_rank [setup] in integrate_mpi
src/integrate_mpi.F90:17: mpi_comm_size [setup] in integrate_mpi
src/integrate_mpi.F90:21: mpi_reduce [collective] in integrate_mpi
src/integrate_mpi.F90:23: mpi_finalize [setup] in integrate_mpi
note: calls by kind: collective 1, setup 4
(10 file(s) analysed)How a call is resolved, the how of each result: contained (a procedure of the caller or of its hosts), generic (every specific of the generic: the compiler chooses by argument types; a generic overloading an intrinsic lists the intrinsic too), binding (every procedure bound to that name in any type: the dynamic type decides), unique (the only project procedure of that name), ambiguous (several), unresolved (nothing in the analysed sources: a library, a dependency, an intrinsic). Callers reached through a generic or a binding with several procedures are marked candidate.
- Qualified names:
module::procedure,module::host::internal.writers module::VARkeeps only the sites where the name is that module's variable, through host association,useor a rename (use m, only: local => var); each site says what the name is there (local, dummy with its intent, host, module variable, use). - Not followed: generics on argument types and bindings on the dynamic type (those give candidates), pointer aliasing, argument association (a variable written through a dummy of another name), components (
obj%fieldis not a variable forwriters/readers). A name re-exported through a chain of modules is attributed to the module the unit uses; a statement only the preprocessor completes is seen as written. - An unknown name gives no result and a note; an unknown query kind or a malformed argument exits 2.
- Checked against a large code: on a private 251-file MPI + OpenACC application (2.1 s to analyse), 34 queries were compared with
grepand, where they disagreed, by reading the code; every disagreement was a sitegrepmissed or misplaced (a call on a continuation line, ause ::form, an assignment after a;), none a wrong answer. On StringiFor (150 files),callers splitfinds 36 call sites, three on a subscripted object (call rows(r)%split(...)).
--json gives kind, argument, items, notes and files, for scripts and agents. Tutorial: querying the code.
Modernise legacy code, verified
A mechanical fix is only as good as the evidence that it changes nothing. fobis modernize applies the five fixable rules of the table above in a scratch copy, and keeps them only when the build, the tests and the declared cases say nothing moved:
fobis modernize # find, apply in a scratch copy, verify, report; writes nothing
fobis modernize --apply # ... and write the verified fixes into the working tree
fobis modernize --select OBS011 # some rules only
fobis modernize --mode debug --keep # the mode built and tested; keep the scratch copies$ fobis modernize
modernize: base copy in ~/tmp/fobis-modernize-yeebvuhj
modernize: checking MDN021, OBS005, OBS011
fixes found:
MDN021: 1 edit(s) in 1 file(s)
OBS005: 1 edit(s) in 1 file(s)
OBS011: 6 edit(s) in 1 file(s)
check [MDN021, OBS005, OBS011]: PASS (evidence: tests)
build: ok
tests: ok
verified: MDN021, OBS005, OBS011
applied: nothing (use --apply)$ fobis modernize --apply
modernize: base copy in ~/tmp/fobis-modernize-h6xnq01a
modernize: checking MDN021, OBS005, OBS011
fixes found:
MDN021: 1 edit(s) in 1 file(s)
OBS005: 1 edit(s) in 1 file(s)
OBS011: 6 edit(s) in 1 file(s)
check [MDN021, OBS005, OBS011]: PASS (evidence: tests)
build: ok
tests: ok
verified: MDN021, OBS005, OBS011
applied: src/romberg.fHow it decides:
- Two copies. The working tree (tracked and untracked files, git-ignored ones left out) and the fetched dependencies are copied into a scratch directory twice: an untouched copy and a fixed one.
- The evidence. The fixed copy must build (when the untouched copy builds: a build failing in both is "fails as in the untouched copy", evidence of nothing); the tests passing on the untouched copy (
fobis testin the mode) must still pass; every case must give equivalent quantities, with the case's tolerance (exact by default). A case already failing on the untouched copy is not used, and the report says so. - Rule by rule. When the whole set fails, each rule is checked alone, and the rules that pass are still verified.
- Writing.
--applywrites the verified rules' fixes, only into files unchanged since the analysis.
- Evidence is named:
tests,cases,tests+cases, orbuild. A build alone does not show unchanged behaviour:--applythen writes nothing (a note says so) unless--unverifiedis given. No evidence at all fails the check. - Suppressions are honoured, so
! fobis: ignore[OBS011] reasonkeeps a loop as it is; the[lint]section and the baseline are not used: every fixable finding of the mode's sources is a candidate. - Exit status: 0 when every rule is verified or nothing needs fixing, 1 when a rule is not verified, 2 for an unknown rule or a bad case declaration.
--jsongivesstatus(verified,partial,failed,nothing),fixesper rule,checks,verified,applied,notes.
Measured on FFTPACK in FORTRAN 77 (59 fixed-form sources, 13 test programs, a fobos from fobis fpm import, one case: the 133 error norms printed by tstfft, compared exactly):
fixes found:
MDN021: 127 edit(s) in 45 file(s)
OBS005: 20 edit(s) in 1 file(s)
OBS011: 542 edit(s) in 39 file(s)
check [MDN021, OBS005, OBS011]: PASS (evidence: tests+cases)
build: fails as in the untouched copy: fftpack::rfft_rk is referenced (referenced from MAIN__) but not linked
tests: ok
cases: equivalent
verified: MDN021, OBS005, OBS011--apply rewrote 46 files and the 13 tests pass on them. The verification earned its place there: a first version of the OBS011 fix put end do at the indentation of a labelled 103 DO 104 line, column 6 in fixed form, the continuation column; dzfftf.f stopped compiling, the check failed on the tests, and the other rules were verified alone.
The edits that need judgement (implicit typing, COMMON to modules, arithmetic IF) follow the same loop, by hand or by an agent: each change checked with fobis diff-run and fobis gate, in Verifying results. Tutorial: verified modernisation.
Call Fortran from C or Python
Most Fortran procedures take kinds like real(R8P), assumed-shape arrays and generic names, and have no bind(c) interface. fobis bindings writes it from the analysis of the modules, without touching them:
fobis bindings # every module of the mode's sources, into bindings/
fobis bindings quad_rules gauss_legendre
fobis bindings m_morton -o c --name morton # into ./c, as morton_c.f90, morton.h, morton.py
fobis bindings --print # what would be bound and why the rest is not; write nothing$ fobis bindings quad_rules gauss_legendre
1 of 4 procedure(s) bound
bound gauss_legendre::gauss_nodes -> gauss_legendre_gauss_nodes
skipped quad_rules::midpoint: argument f: procedure arguments are not bound
skipped quad_rules::trapezoid: argument f: procedure arguments are not bound
skipped quad_rules::simpson: argument f: procedure arguments are not bound
written: bindings/quad_c.f90, bindings/quad.h, bindings/quad.pyThree files, named after --name, else the fobos [project] name, else the current directory (lowercase, - as _):
$ cat bindings/quad_c.f90
module quad_c
!< bind(c) shims generated by fobis bindings: do not edit, regenerate.
use, intrinsic :: iso_c_binding
use gauss_legendre
use quad_rules, only : R8P
implicit none
private
public :: gauss_legendre_gauss_nodes
contains
subroutine gauss_legendre_gauss_nodes(n, x, w) bind(c, name="gauss_legendre_gauss_nodes")
integer(c_int32_t), intent(in) :: n
integer :: n_f
real(c_double), intent(out) :: x(n)
real(r8p) :: x_f(n)
real(c_double), intent(out) :: w(n)
real(r8p) :: w_f(n)
n_f = int(n, kind(1))
call gauss_nodes(n=n_f, x=x_f, w=w_f)
x = real(x_f, c_double)
w = real(w_f, c_double)
end subroutine gauss_legendre_gauss_nodes
end module quad_c$ cat bindings/quad.h
/* C interface of quad: generated by fobis bindings, do not edit. Link with the library built from the
project's sources and quad_c.f90. Arrays are Fortran arrays: contiguous, column-major. */
#ifndef QUAD_H
#define QUAD_H
#include <stdbool.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
/* gauss_legendre::gauss_nodes(n intent(in), x(n) intent(out), w(n) intent(out)) */
void gauss_legendre_gauss_nodes(const int32_t *n, double *x, double *w);
#ifdef __cplusplus
}
#endif
#endif /* QUAD_H */NAME_c.f90 is a module of bind(c) shims named <module>_<procedure>; NAME.h the C prototypes (stdint.h, stdbool.h types), each with the Fortran signature it calls; NAME.py a ctypes wrapper: load(path), then one function per procedure taking numbers and lists, returning the result and the intent(out)/inout arguments. Build the shim with the modules into a shared library, a mode like the tutorial's python:
[python]
help = The C and Python bindings, a shared library
template = template-gnu
cflags = -c -O2 -fPIC
src = ./src/ ./bindings/
build_dir = ./build/python/
target = bindings/quad_c.f90
mklib = shared
output = libquad_c.so$ fobis build --mode python --quiet
Building src/romberg.f
Compiling src/romberg.f serially
Building bindings/quad_c.f90
Compiling src/quad_rules.F90 serially
Compiling src/gauss_legendre.F90 serially
Compiling bindings/quad_c.f90 serially
Linking build/python/libquad_c.so
Target bindings/quad_c.f90 has been successfully built"""The 3-point Gauss-Legendre rule, from Fortran, through the generated ctypes wrapper."""
import sys
sys.path.insert(0, "bindings")
import quad # bindings/quad.py, written by fobis bindings
quad.load("build/python/libquad_c.so")
x, w = quad.gauss_nodes(3, [0.0] * 3, [0.0] * 3)
print("nodes ", " ".join(f"{v:+.15f}" for v in x))
print("weights", " ".join(f"{v:.15f}" for v in w))
# exact up to degree 2n - 1 = 5: the integral of x**4 on [-1, 1] is 2/5
print("integral of x**4:", sum(wi * xi**4 for xi, wi in zip(x, w, strict=True)))$ python3 gauss.py
nodes -0.774596669241483 +0.000000000000000 +0.774596669241483
weights 0.555555555555555 0.888888888888889 0.555555555555555
integral of x**4: 0.3999999999999998- C types from what the kind resolves to, not from its name:
selected_int_kind(15)isint64_t,selected_real_kind(15, 307)(PENF'sR8P) isdouble;iso_fortran_envandiso_c_bindingnames and literal kinds too. Defaultinteger,realandlogicalareint32_t,floatandbool, as gfortran, ifx and nvfortran use them without-i8/-r8. - Explicit conversions. The shim copies every value into a temporary declared with the procedure's own type spec (
x_f = real(x, R8P)) and back forintent(out)/inoutand results, and repeats the module'susestatements, which give the kinds. A dummy withoutintentis treated asinout. - Arrays: an assumed-shape
v(:)gets an extent argument per dimension; explicit-shape arrays keep their extents (literals, or other dummies); a function returning an array of literal shape returns it through an output argument. Arrays are contiguous and column-major; Python passes and returns flat lists in Fortran order, and an output array is passed too: its length sizes the buffer. - Generics: the specifics of a public generic are bound one by one; a private specific is called through its generic, which resolves to it because the temporaries have the specific's types.
- Not bound, with the reason in the report: character, complex, derived-type and procedure arguments; optional, allocatable and pointer dummies; assumed-size and assumed-rank arrays, bounds that are not literal extents, extents that are expressions; kinds with no portable C type (quad precision) or that cannot be resolved; array results without a literal shape, allocatable or pointer results; procedures already
bind(c). - Files: nothing is written when nothing can be bound; existing files are kept (exit 2) unless
--force; an unknown module, or sources without a module, exit 2.--jsonlists each procedure (module,procedure,c_name,kind,skipped) and the files written.
Measured: m_morton (8 public procedures: fixed-shape arguments, array results, selected_int_kind(15)) bound entirely, built with gfortran and called from gcc and Python with the same results; PENF (170 public procedures and specifics) bound 15 and reported the rest (98 with allocatable arguments, 35 character, 17 optional, 5 the 33-digit R16P). Tutorial: C and Python bindings.
For CI and agents
fobis lint --format json, fobis review --format json, fobis query --json, fobis modernize --json and fobis bindings --json print one JSON document on standard output. The MCP server (fobis mcp) exposes lint (with a fix diff), review, query, bindings (the plan, read-only) and modernize (never applies) as tools, and the rules as resources. Briefing an agent on the project (fobis agents-md): AI setup.