airom fs <path> walks a directory tree, runs the detectors over it, and emits an AIBOM.
Scan a directory
The path must be a directory. Single-file scanning is not supported — point AIROM at the containing directory. A path that cannot be read at all is a fatal error (exit code 2); unreadable files below the root degrade to Unknown records and the scan continues.
airom scan ./my-ai-app resolves to the same thing: scan tries an existing local path first. Use dir: to force it.

What gets walked

Paths in the output are root-relative and slash-separated, regardless of platform.

Ignore handling

Four layers decide whether a path is walked. They are evaluated in this order:
1

Default skips (non-overridable)

VCS and dependency internals are always skipped: .git/, node_modules/, vendor/, .venv/, venv/, __pycache__/, .tox/, .mypy_cache/.These live in an isolated matcher consulted before your ignore stack, so no ! re-inclusion rule can bring them back. “Always-on” is enforced, not aspirational.
2

.gitignore

Standard gitignore syntax with the usual nested, per-directory semantics. A .gitignore in a subdirectory scopes its patterns to that subtree.
3

.airomignore

Same syntax, same nested semantics, read after .gitignore in each directory — so it is applied on top and can re-include (!) what .gitignore excluded. Use it for AIROM-specific exclusions you don’t want in .gitignore.
4

--ignore globs

Repeatable doublestar globs matched against root-relative paths, applied on top of the ignore-file stack. An invalid glob is a startup error, not a silent no-op.
Prune fixtures and test data
A glob that targets a directory prunes the directory itself, not just its contents — **/fixtures/** will not descend into fixtures/.
On macOS and Windows, ignore matching folds case, mirroring git’s default core.ignorecase=true on those platforms. AIROM skips what your git skips.
POSIX character classes (e.g. [[:digit:]]) are not supported in ignore patterns by the underlying matcher. Use plain gitignore globs instead.
An ignored path is excluded from both walk phases. A detector that queries files by glob during the project phase sees exactly what the walk saw — it can never reach behind an ignore rule.

Frozen applications

A PyInstaller onefile executable is a blind spot with no partial answer: every module is compiled into a compressed PYZ appended to the bootloader, so there is no .py, no .pyc, and usually no dist-info anywhere on disk. Import rules and manifest detectors both find exactly nothing. AIROM reads the archive itself — the CArchive cookie at EOF, its table of contents, and the PYZ module list — and reports the AI packages inside. The version comes from whichever of three sources can answer, and the evidence says which: That third row is deliberate. The package is demonstrably in the binary, and staying silent about it would read as absence. Some packages ship no metadata at all, so the __version__ path is the only way they can ever be versioned.
No code object is ever unmarshaled. Only the handful of marshal types a PYZ directory can contain are implemented, and everything else — TYPE_CODE above all — is refused. Version strings are recovered from decompressed bytes instead. marshal on untrusted input is a code-execution surface, and a scanner that reads other people’s executables must not be the thing that runs them.
This needs random access to seek to the end of the file, so it works on a filesystem or repository scan but not on airom image or a tar stream, which are consume-once. There the binary is recorded as an Unknown rather than silently reported as containing no AI.

Server-side database extensions

A vector database is not always something a project imports. pgvector is a PostgreSQL extension: it shows up in DDL, and otherwise only as files the server installation left on disk. A host running it has no manifest entry and no import for a source rule to match. AIROM reads the extension’s own control file — share/extension/vector.control — which names the version outright, and the loadable module beside it: All three fold into one component, so a host with the extension installed and a schema that uses it reports pgvector 0.8.1 once, with every sighting behind it, rather than three partial findings.
default_version is the version installed on disk. A database created before an upgrade keeps running the older one until ALTER EXTENSION ... UPDATE, so the two can differ — the evidence string says so rather than implying the running database was inspected.
The module rule is path-anchored to PostgreSQL library directories on purpose. A file named vector.so anywhere else is far too generic to claim a database extension from.

Bounding the scan

Peak memory is a function of these caps, never of the size of the tree.
string
default:"1m"
Full-content read cap for text detectors. Accepts k/m/g suffixes. Header-only binary parsers (GGUF, safetensors, …) are exempt — a 40 GB model file still costs only a header read.
string
default:"256m"
Byte-weighted I/O semaphore budget, independent of CPU parallelism. Accepts k/m/g suffixes.
int
default:"GOMAXPROCS"
Detector worker count.
Tune for a large monorepo

Narrowing the detectors

--select takes a detector selection expression. Bare names set the base set; + adds and - removes.
Python detectors, plus GGUF, minus dataset
Check what a selection resolves to before you commit to it:
See the effective selection

Filtering the results

Extension-only dataset detection and keyword-only ai-config detection are deliberately shallow, and on a general-purpose directory they emit low-confidence (0.5–0.6) noise. --min-confidence 0.8 is the practical filter.
Scan, filter, write JSON
--min-confidence is a presentation-layer filter — it changes what is emitted, not what was detected. See /concepts/confidence.

Example

Default table output
Findings are not failures. airom fs exits 0 whether it finds seven components or none. To fail a build on what it finds, use --fail-on — see /reference/cli.