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, so 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, so 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. It then 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 is refused, TYPE_CODE above all. 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, so 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.