Plugins
New languages and frameworks, SCIP indexes, language servers, trusted mode
Onus builds a map with providers:
- discovery providers find components (built in:
onus.yaml, Nx projects, npm/pnpm/yarn workspaces, folders); - language providers turn source files into symbols, shapes, references and tests (built in: TypeScript and JavaScript on tree-sitter);
- fact providers add facts to the map built so far (SCIP indexes, framework plugins).
--plugins <file> (or ONUS_PLUGINS). The plugins file belongs to whoever runs Onus: Onus never reads plugins from the analyzed repository, so a pull request cannot make CI run new code.
Framework packs: no code at all
Most framework knowledge is a pattern in the code: a decorator, a method on a known client, a call with a literal argument. A pack is a YAML file of tree-sitter queries and what a match means. Packs never run code, so they may be listed in the analyzed repository's onus.yaml (extractors.packs) as well as in the plugins file (packs). Like the rest of onus.yaml, packs are read from the base version of a change: a pull request cannot edit a pack to hide what it adds, and editing one is reported as a rules-of-the-game change.
pack: nestjs
rules:
- id: route
query: |
((decorator (call_expression
function: (identifier) @method
arguments: (arguments . (string) @path)))
(#match? @method "^(Get|Post|Put|Patch|Delete)$"))
emit: { route: "{method|upper} {path}" }
- id: on-event
query: |
((decorator (call_expression
function: (identifier) @fn
arguments: (arguments . (_) @name)))
(#eq? @fn "OnEvent"))
emit: { event: consumes, name: "@name" }
- id: cache-write
query: |
((call_expression function: (member_expression
object: (identifier) @c property: (property_identifier) @op))
(#eq? @c "redis") (#eq? @op "set"))
emit: { edge: writes, to: "db-table:redis-cache" }Each rule has an id, a tree-sitter query over the TypeScript grammar (the same query runs on TSX and JavaScript), and one emit:
| Emit | Means |
|---|---|
event: publishes or consumes, name: "@capture" | An event, named by the capture; literals, constants and enum members are resolved |
data: reads or writes, table: "<template>" | A table read or written |
config: "<template>" | A configuration key read |
host: "@capture" | An outbound call to the host of the captured literal URL |
route: "<template>" | An HTTP route served here: a public contract, so an added or removed route is a contract change |
edge: <kind>, to: "<template>" | Any other relationship (calls, reads, writes, publishes, consumes, calls-external, reads-config, ...) |
diagnostic: <kind>, message: "<text>" | A map confidence note |
{capture} with the captured text (a string literal without its quotes) and accept {capture|upper} and {capture|lower}. Facts are attributed to the innermost declaration containing the match. Query predicates (#eq?, #match?, #any-of?) work as in tree-sitter.
Onus's own framework knowledge ships as packs too: Prisma reads and writes, process.env reads, fetch/axios/got/ky hosts, and the event patterns from onus.yaml (crates/onus-lang-ts/packs/ in the repository). An invalid pack is a configuration error that names the pack and rule.
The plugins file
plugins:
# A plugin speaking the Onus protocol: finds Go modules as components.
- name: go-modules
kind: discovery
command: [onus-plugin-example]
# A framework plugin: Express routes become contracts.
- name: express-routes
kind: facts
command: [onus-plugin-example]
# A SCIP indexer, run in the tree; it must write {out}/index.scip.
- name: rust
kind: scip
command: [rust-analyzer, scip, ".", --output, "{out}/index.scip"]
# A language server, driven over LSP.
- name: python
kind: lsp
command: [pyright-langserver, --stdio]
files: ["**/*.py"]
language_id: python
packs: [packs/nestjs.yaml] # framework packs, relative to this file
sandbox:
preset: auto # auto | bwrap | sandbox-exec | container | none
# command: [docker, run, --rm, -i, --network, none, -v, "{root}:{root}:ro", -v, "{out}:{out}", -w, "{root}", my-image]Each plugin has:
name: recorded in the map'sbuiltWith.providers;kind:discovery,language,facts,sciporlsp;command: the program and its arguments.{root}becomes the tree's root and{out}a fresh, writable temporary directory;files(language and lsp): globs of the files it handles;language_id(lsp): the LSP language id of those files;runs_repo_code: whether the tool may run code from the repository. Default: true forscipandlsp, false otherwise;timeout_seconds: default 300.
Trusted mode and the sandbox
Language servers and indexers are the most precise source of facts, because they are built on each language's own compiler. They also tend to run code from the repository: rust-analyzer runs build scripts and procedural macros, gopls runs go list, tsserver loads plugins named in tsconfig, Gradle and Maven run build scripts. Most of them also need dependencies installed.
So a plugin with runs_repo_code: true runs only with --trusted, and only inside a sandbox with no network:
auto: bubblewrap (bwrap) on Linux,sandbox-execon macOS;bwraporsandbox-exec: that one;container: Docker or Podman (runtime) runningimage, with no network, the tree read-only and{out}writable; the tools must be in the image. With Docker Desktop or Colima, the tree and the temporary folder (TMPDIR) must be in a shared folder. This is the option on Windows;- a custom
commandprefix; none: no sandbox; such plugins then also need--allow-unsandboxed.
{out} and the system's temporary folders are writable.
Without --trusted they are skipped, and both the map and the report say so ("provider rust skipped: ... runs only with --trusted"). Use trusted mode where the code is already trusted, for example on the main branch, and keep pull requests from forks on the default providers, which only read files.
Importing an index that was produced elsewhere runs nothing and needs no trusted mode:
onus map . --scip index.scip
onus diff old new --base-scip old.scip --head-scip new.scipSCIP indexes
SCIP indexers index a whole repository in one pass:
| Language | Indexer | Command |
|---|---|---|
| Rust | rust-analyzer | rust-analyzer scip . --output {out}/index.scip |
| TypeScript, JavaScript | scip-typescript | scip-typescript index --output {out}/index.scip |
| Python | scip-python | scip-python index . --output {out}/index.scip |
| Java, Scala, Kotlin | scip-java | scip-java index --output {out}/index.scip |
| Go | scip-go | scip-go --output {out}/index.scip |
Tried: rust-analyzer indexed the Onus repository in about 3.5 seconds inside the macOS sandbox, and scip-typescript indexed an 8,500-file TypeScript monorepo in about 20 seconds, adding 10,600 compiler-confirmed references.
What an index adds:
- for files another provider analyzed: references that provider missed, such as a method call through a variable, and confirmation of the ones it found (confidence
compiler); - for files no provider handles: their symbols, with the signature as shape, and their references. A symbol used from another component is public.
Language servers
For languages without an indexer, the LSP bridge starts the server, opens each file it handles, waits until the server has finished loading the project (it follows the server's progress reports), and asks for document symbols and, where the server supports it, the call hierarchy. Symbols become map symbols (their detail, usually the signature, is the shape); outgoing calls become calls edges with confidence compiler. Language servers answer one request at a time, so an index is faster on large repositories. Measured: the bridge itself handles about 15,000 requests a second, so the server sets the pace; pyright (pyright-langserver --stdio, language id python) mapped a generated 500-file, 35,000-line project in 2.6 seconds, and rust-analyzer (rust-analyzer, language id rust) mapped one Rust crate in 4.5 seconds, most of it loading the Cargo workspace.
Svelte
Svelte components are supported by a plugin that ships with Onus, onus-plugin-svelte:
plugins:
- { name: svelte, kind: language, command: [onus-plugin-svelte], files: ["**/*.svelte"] }It analyzes each component's <script> blocks with the TypeScript adapter, together with the repository's TypeScript files, so imports such as $lib/api resolve and line numbers are exact. Expressions in the markup are not analyzed, but a change to the markup is still a change: components are compared whole. It reads files only, so it needs no trusted mode.
Writing a plugin
A plugin is any program. Onus starts its command, writes one JSON request to its stdin and reads one JSON response from its stdout; anything on stderr is shown if the plugin fails. The protocol is versioned and published as schemas/plugin-protocol.schema.json.
A request:
{
"protocol": 1,
"kind": "facts",
"workspace": {
"root": "/abs/path/to/tree",
"files": [{ "path": "services/api/src/app.ts", "component": "api", "isTest": false }],
"components": [{ "id": "api", "kind": "service", "roots": ["services/api/**"], ... }],
"packages": {},
"extractors": { ... }
},
"map": { "files": [...], "symbols": [...], "edges": [...], "tests": [...], "diagnostics": [...] }
}discoveryrequests list every file and ask forcomponents.languagerequests list only the files the plugin handles that no earlier provider claimed (the built-in TypeScript adapter comes first); any file underrootcan still be read.factsrequests include the map built so far.
{
"protocol": 1,
"version": "my-plugin 1.2.0",
"components": [],
"map": { "symbols": [...], "edges": [...] }
}Everything in map uses the map's own types (schemas/codebase-map.schema.json). Ids follow the map's rules: <component>:<path inside the component>#<name> for symbols, and event:, db-table:, config-key:, external:, npm: for shared nodes.
Onus treats plugin output as untrusted input. It drops any path outside the tree, any file the tree does not contain, and any id of an unknown component, notes what it dropped (plugin-output-dropped), and sorts everything, so a report stays deterministic as long as the plugin is.
The reference plugin, crates/onus-plugin-example in the repository, implements discovery (Go modules) and facts (Express-style HTTP routes, which become public contracts, so added or removed routes show up in reports) in about 150 lines.
Order and precedence
- Discovery:
onus.yamlcomponents, when declared, are final. Otherwise built-in discovery runs first and discovery plugins add components; for each file, the most specific component wins. - Languages: the built-in TypeScript adapter, then language plugins, then language servers, in the order listed. Each file belongs to the first provider that handles it.
- Facts: imported indexes (
--scip), then SCIP indexer plugins, then fact plugins, in the order listed. Each sees what came before.