Skip to content

Component Autodetect

The algorithm by which aburi init and aburi scan (when no config is present) infer logical Components from the physical structure of a monorepo. If the config explicitly declares components[], it takes precedence over the autodetect result (config.md §6.1).

References:

  • ir-schema.md §4 — Component structure
  • config.md §6 / §12 — Component override / autodetect overview

1. Purpose

Aburi infers "which set of files forms one logical unit (Component)" with zero user input.

This enables:

  • aburi init to generate aburi.json with pre-populated components[]
  • aburi scan to work even without aburi.json
  • Newcomers to get an overview of the workspace structure before writing any config

2. Two-stage algorithm

1. Workspace root detection
   - Walk upward from the CLI execution cwd
   - Take the outermost workspace marker as root

2. Component extraction
   - Run each detector in parallel from the root
   - Merge and dedupe detection results
   - Fill in id / name / languages / frameworks / publicApi / description via inference rules

2.1 Workspace root detection

Walk from cwd toward parents; the workspace root is the outermost (closest to the filesystem root) directory in which any of the following is found:

  • .git/ directory
  • pnpm-workspace.yaml
  • turbo.json
  • nx.json
  • lerna.json
  • go.work
  • Cargo.toml (containing a [workspace] section)
  • pyproject.toml (containing [tool.uv.workspace] / [tool.hatch.workspaces] / [tool.poetry])
  • package.json (containing a workspaces field)
  • .aburi-workspace (reserved for future use, Aburi-specific marker)

If markers are found at multiple levels, the outer one wins (e.g., the parent holding .git is the true root of the monorepo).

2.2 Component extraction

Each detector receives the workspace root and determines whether a marker it can handle exists. If so, it returns zero or more "workspace candidates".

detector.detect(workspaceRoot) → {
  manager: WorkspaceManager  // tool name + roots
  workspaces: Workspace[]
} | null

All detector results are merged, duplicates with the same path are removed, and each workspace is then converted into a Component.

3. Detectors

3.1 JS/TS ecosystem

DetectorMarkerWorkspace extraction method
pnpmpnpm-workspace.yamlResolve the globs in the packages: field
npm/yarnworkspaces in package.jsonResolve globs from the array or {packages: [...]}
bunSame as above (npm-compatible)Same as above
turboturbo.jsonTreated as a monorepo hint. Actual workspaces come from pnpm/npm detectors
nxnx.json + project.jsonEvery directory containing a project.json

3.1.1 Glob resolution conventions

  • POSIX globs (forward slashes)
  • Relative to the workspace root
  • A pattern names a directory that holds the manifest, and is resolved against the manifest: p is matched as p/package.json (a trailing slash replaced), and the directory holding each match is the candidate. This is how pnpm and npm resolve these patterns, and it is the same rule the nx detector follows with project.json. Three consequences worth stating:
    • . and ./ name the workspace root itself, and nothing else. Matched as a directory instead, . is a pattern that reaches every directory in the workspace.
    • A literal path names that directory, not its subtree.
    • A matched directory with no manifest is not a package, and is not a candidate.
  • A negated pattern (!packages/legacy) takes the same transform and removes the package it names
  • An empty pattern declares nothing
  • package.json is the only manifest recognized. pnpm also accepts package.yaml and package.json5, and globs package.{json,yaml,json5}; a package declared in either is not detected here, because the manifest is parsed as JSON for the id, name, frameworks and public API. Supporting them is a parser change, not a resolution one.
  • Which resolved directories become Components is §5's rule, not the manager's. pnpm counts the workspace root as a workspace project unconditionally — "The root package is always included, even when custom location wildcards are used" — and a pattern naming it changes nothing for pnpm. Here it does: the root is a Component when a pattern names it, and otherwise only through §5's no-detector fallback. A root Component's roots: ["."] contains every other component's root, so making one unconditionally would give every workspace a component whose census covers the whole tree.
  • Maximum depth of ** is 10 (to avoid false positives)
  • A path matching multiple globs counts as a single entry
  • Anything under node_modules/ is always excluded

3.2 Go ecosystem

DetectorMarkerWorkspace extraction method
gogo.workEnumerate the directories in use ./module-a

If there is no go.work but a standalone go.mod, the root is a single module = a single Component.

3.3 Rust ecosystem

DetectorMarkerWorkspace extraction method
cargoCargo.toml with [workspace]Resolve the globs in members = [...]

If there is a standalone Cargo.toml without [workspace], the root is a single crate = a single Component.

3.4 Python ecosystem

DetectorMarkerWorkspace extraction method
uvpyproject.toml with [tool.uv.workspace]members = [...]
hatchpyproject.toml with [tool.hatch.workspaces]members
poetry (multi-project)Search for multiple pyproject.toml files with [tool.poetry.dependencies]Recursive detection of subdirectories

Python workspace standards are fragmented, so multiple detectors exist.

3.5 Others (planned)

ManagerMarkerNotes
Lernalerna.jsonOverlaps with npm/yarn detection
BazelWORKSPACE / WORKSPACE.bazel / MODULE.bazelExtract workspaces from BUILD files
Mavenpom.xml with <modules>Parent project + modules
Gradlesettings.gradle(.kts) with include(...)Included projects
Elixirmix.exs umbrella projectEverything under apps_path
Composercomposer.json (when using composer/installers)Not generalized; deferred

Currently only JS/TS detectors are implemented. For the others, the detector plugin interface is defined so that each language plugin can add detectors in a future release (§7; see the roadmap).

4. Inference of Component fields

Mapping from each workspace to a Component.

4.1 id

Priority order:

  1. package.json#name (JS/TS): strip the scope and kebab-case it (@scope/billingbilling)
  2. project.json#name (nx): the project name, for a directory that has no package.json
  3. package.name in Cargo.toml (Rust)
  4. project.name in pyproject.toml (Python)
  5. Trailing segment of the module name in go.mod (Go)
  6. Kebab-case the trailing segment of the workspace directory's full path

This is a priority over sources, not a single source: neither a manifest that carries no name nor one whose name yields no id is an answer, and the next source is asked before the directory name is. @scope/ is a name §4.2 can use and §4.1 cannot, so a Component can take its id and its name from different manifests.

A directory that several detectors claim is described by all of their manifests at once (§7), and they are read in this order — by filename, so the order the detectors happened to run in cannot move an id. The package.json under a candidate's root is read whether or not a detector reported it: a directory holding one is an npm package however it was found, and nx reports only project.json.

On collision (multiple workspaces yielding the same id) → append the parent directory name as a suffix (billingbilling-apps / billing-packages). When the parent segment kebab-cases to nothing, the id is left unsuffixed and the numeric-suffix pass (billing-2, billing-3) resolves the collision instead.

The result must satisfy aburi.ir.v1.json#/$defs/ComponentId. Names that kebab-case to the empty string — a directory whose name is entirely non-ASCII, say — cannot yield an id, and detection aborts with invalid-component-id naming the manifest or directory it came from. Declare the component explicitly in aburi.json components[] to override the derivation.

4.2 name

Priority order:

  1. Full name from package.json#name (including the scope, e.g. @scope/billing)
  2. The name field of other manifests, in §4.1's order
  3. The trailing segment of the workspace directory as-is (capitalized)

4.3 roots

A single element: the detected workspace path. POSIX path relative to the workspace root.

4.4 languages

Shallow-scan each component's subtree (up to 3 directory levels below its own root) and tally extension frequencies, over the files the workspace has not excluded — see §8. Include the language id for each extension exceeding the threshold (>5% and >10 files).

The census counts every extension in the table below, whether or not a lang plugin claims it. That is deliberate and is the point of the paragraph after next: the field answers "what is this component written in", not "what did this run parse", and aburi init has to answer it before any plugin is resolved. A file too large for maxFileSizeBytes counts for the same reason.

Minority-language files below the threshold are handled as follows:

  • Not included in Component.languages[] (autodetect lists primary languages only)
  • However, if the corresponding lang plugin is enabled, those files are still scanned normally and Symbols are extracted
  • "Symbols in a language not listed in Component.languages" is normal (e.g., a mostly-TS component containing a few .py scripts with the py plugin enabled)
  • Files in a language with no corresponding lang plugin are skipped with a warning (same handling as §6.5)

The role of Component.languages[] is to indicate "the primary languages needed to understand this component". It is independent of the Symbol extraction scope.

Extension mapping:

Extensionlanguage id
.ts/.mts/.ctsts
.tsxtsx
.js/.mjs/.cjsjs
.jsxjsx
.pypy
.gogo
.rsrs
.javajava
.kt/.ktskt
.scalascala
.rbrb
.phpphp
.cscs
.swiftswift
.ex/.exsex

This mapping is not fixed: it can be reverse-derived from the fileExtensions each language plugin declares (lang-plugin.md §4.1).

4.5 frameworks

Detect known frameworks from dependency manifests:

SourceDetection pattern → framework id
package.json deps/devDeps@nestjs/corenestjs
Same as abovenextnextjs
Same as abovereact (with react-dom) → react
Same as abovevuevue
Same as aboveexpressexpress
Same as abovefastifyfastify
Same as abovekoakoa
Same as abovehonohono
Same as aboveastroastro
Same as abovesvelte (kit/dev) → svelte
Same as abovesolid-jssolid
Same as above@trpc/servertrpc
go.modgithub.com/gin-gonic/gingin
Same as abovegithub.com/labstack/echoecho
Same as abovegithub.com/gofiber/fiberfiber
pyproject.toml depsdjangodjango
Same as abovefastapifastapi
Same as aboveflaskflask

The list is owned by the Aburi core, but a mechanism is planned whereby a framework plugin, once it declares its name in manifest.provides.frameworks[], can extend the "detection pattern → name" mapping on the plugin side (see the roadmap). Today the fixed core list is used.

Detected frameworks are only recorded in Component.frameworks[]; the corresponding plugin is not auto-enabled (config.md §15.1).

4.6 publicApi

Resolve exports / main / module / types from package.json:

jsonc
// package.json
{
  "exports": {
    ".": "./src/index.ts",
    "./client": "./src/client.ts"
  }
}

jsonc
{
  "publicApi": ["src/index.ts", "src/client.ts"]
}

If there is no exports, take the file path from main / module / types. If none exist, the publicApi key is omitted — it is Class B under ir-schema.md §1.1, so "no public surface declared" is spelled as an absent key, never as []. frameworks follows the same rule; description is Class A and is written as an explicit null when detection has nothing to put there.

For Python / Go / Rust, each language plugin will provide "public API file" inference logic in a future release (see the roadmap). Today only JS/TS is covered.

5. Single project (non-monorepo)

If no detector hits, the workspace root is treated as a single Component:

jsonc
{
  "id": "<inferred from package.json name>",   // or the workspace root's directory name
  "name": "<same as above>",
  "roots": ["."]
}

This lets Aburi work even for the smallest single-project setups (e.g., a standalone TypeScript repository).

"No detector hits" means no candidate directory, not no marker. A manifest that parsed and declared no package reaches the same fallback, and there are two ways to get there:

  • A pnpm-workspace.yaml whose packages: field is absent or empty, or a package.json with no workspaces. pnpm reads the first the same way — "If the packages field is omitted, only the root package is included in the workspace" — and an empty list identically, so the whole repository as one Component is the right answer.
  • Patterns that matched nothing: a mistyped pattern, a monorepo with no packages in it yet, or packages whose manifest is one §3.1.1 does not recognize. The whole repository becomes one Component, which is the wrong answer for a workspace that declared packages — so detection reports it and aburi scan and aburi init name it on stderr. One entry per manifest, not per manager: pnpm-lock.yaml beside a package.json#workspaces makes both manifests spell pnpm, so the manifest path is what identifies the file to open and what orders two reports. The IR itself keeps no trace a reader can act on: workspace.managers[].roots is empty, which is also what a turbo co-marker writes on purpose.

A third shape does not reach the fallback at all. A packages: or workspaces that is present and is not a list of strings — a trailing colon making the entry a map, a bare scalar, a number in the list — is refused with workspace-manifest-malformed naming the manifest and the entry. pnpm refuses all three itself, the remedy differs from a dead pattern's (write it as a list, rather than fix the pattern), and filtering the element away quietly would put every package the manifest meant to declare on the fallback with nothing said.

6. Detector extension mechanism

Language plugins can provide additional detectors:

ts
interface ComponentDetector {
  id: string                                 // "uv", "cargo", "go-work", etc.
  detect(workspaceRoot: string): DetectorResult | null
}

interface DetectorResult {
  manager: WorkspaceManager                  // an element of workspace.managers[] per ir-schema §2
  workspaces: WorkspaceCandidate[]
}

interface WorkspaceCandidate {
  root: string                               // path relative to workspace root
  manifestPath: string                       // path to package.json, etc.
  rawMetadata: unknown                       // raw parse result of the manifest (aids Component inference)
}

Allowing each language plugin's manifest.provides to include a detector is a planned extension (see the roadmap); today the fixed core detector set is used.

6.5 Handling mixed languages/runtimes

A monorepo mixing multiple managers, such as apps/web (pnpm) + apps/api (cargo) + services/ml (uv), lets each detector hit independently.

  • Record all detected tools in workspace.managers[] (e.g. [{tool:"pnpm",...}, {tool:"cargo",...}, {tool:"uv",...}])
  • Each workspace becomes a Component under its own manager's naming conventions
  • Each Component's languages is determined automatically by scanning its subtree (§4.4)
  • Since only TS is supported today, when a non-TS workspace is detected:
    • The Component is still created (to preserve the full architectural picture)
    • The language is included in languages, but Symbol extraction is skipped
    • Recorded in stats.skippedFiles[] with reason: "unroutable" — that reason covers both ways a file has no route into the Document, and here it is the first: no loaded plugin claims the extension (to be revisited when more languages land — see the roadmap)
    • Warning on stderr: Component <id> has language <lang> but no lang plugin enabled. Symbols not extracted.

7. Conflict resolution

When multiple detectors return the same path (e.g., both pnpm and turbo detect packages/billing):

  • Merge into one workspace candidate (dedupe by path)
  • Record manager information from both (two entries in workspace.managers[])
  • A single Component
  • Keep every manifest the detectors found for that directory, plus the package.json under its root whether or not one of them reported it, and read them in §4.1's order for id and name. A directory claimed by pnpm and nx at once has a package.json and a project.json, and they routinely name it differently: the first is the published npm name the rest of the Document is written against, the second an nx project name
  • A manifest that is present and cannot be read — bad JSON, or an IO failure that is not "no such file" — aborts detection with workspace-manifest-malformed naming the file. Absent is the ordinary case and says nothing; unreadable is an identity this run cannot see, and answering with the next manifest's name would hide it
  • frameworks (§4.5) and publicApi (§4.6) are read from the package.json alone. They are defined over dependencies and exports, which are npm's fields; an nx project.json holds targets whose options are arbitrary JSON, so a key of either name in one is not the npm field it resembles

When multiple detectors generate the same id with different paths (§4.1):

  • Append suffixes per the collision-avoidance convention in §4.1

8. .gitignore / exclusions

  • Honour .gitignore per config.respectGitignore, by the rules in drop-list.md §3.3 — every directory's file, asked about each candidate rather than folded into the traversal's exclusion globs
  • Category A's core patterns apply too, the same list discovery uses rather than a copy of part of it: node_modules/, vendor/, __pycache__/, out/, .venv/, *.d.ts and the rest of drop-list.md §3.1
  • config.ignore[] and the loaded language plugins' file-drop globs apply when the caller has them. The census is one walk from the workspace root, bucketed by component root afterwards, because those patterns are workspace-root relative by contract and cannot be matched against a walk rooted inside a package
  • The one caller that has neither is aburi init, which detects components in order to write the first config. It honours .gitignore and the core patterns, which is everything knowable before a config exists
  • The contents of .git/ are never read, but its presence is used as a workspace root marker

9. Performance

The autodetect portion of aburi init / aburi scan targets <200ms (medium-sized monorepo).

Implementation guidance:

  • Filesystem traversal is asynchronous and parallel
  • Manifest reads are memoized
  • Glob resolution uses an efficient fast-glob-style implementation

10. Verifiable properties

IDInputExpected
CD1pnpm-workspace.yaml: packages: ["packages/*"] + packages/{a,b,c}/package.json3 Components detected, ids from each package.json#name
CD2npm workspaces ["apps/*", "libs/*"]Every package under apps and libs becomes a Component
CD3turbo.json + pnpm-workspace.yamlSame workspace as 1 Component, 2 entries in managers[]
CD4Cargo.toml [workspace] members = ["crate-a"]1 Component (crate-a)
CD5go.work use ./mod-a ./mod-b2 Components
CD6Single TS project with no markers1 Component (root, id = package.json name)
CD7package.json#name = "@scope/billing"Component.id = "billing", Component.name = "@scope/billing"
CD82 workspaces generate the same id (both "shared")Suffixes appended ("shared-apps" / "shared-packages")
CD9dependencies: {"@nestjs/core": "..."}frameworks = ["nestjs"]
CD10package.json#exports: {".": "./src/index.ts"}publicApi = ["src/index.ts"]
CD11Autodetect finds nothing and there is no package.jsonid = directory name (kebab-case), name = directory name
CD12config.components overrides after autodetectconfig wins (config.md §6.1)
CD13Both pnpm and nx detect the same workspaceDeduped into 1 Component
CD14packages: [".", "packages/*"] on a tree that also holds src/ and a/b/c/d/2 Components: the workspace root (roots: ["."]) and the package
CD15packages: ["packages/*"] where packages/dist/ holds no manifestpackages/dist is not a Component
CD16packages: ["packages/*"] where no matched directory holds a manifestmanagers[] records pnpm with roots: [], §5's fallback makes the whole repository one Component, and both facts are named on stderr
CD17pnpm and nx both claim apps/billing; package.json#name = "@acme/billing-api", project.json#name = "billing-e2e"1 Component, id billing-api, name @acme/billing-api, with the package.json's frameworks and publicApi
CD18nx alone claims apps/billing, project.json#name = "billing-web" and a dependencies key in itid and name billing-web, no frameworks, no publicApi
CD19nx alone claims apps/billing, and a package.json sits beside the project.jsonIdentity, frameworks and publicApi come from the package.json
CD20apps/billing/package.json is not valid JSONDetection aborts with workspace-manifest-malformed naming the file
CD21pnpm-workspace.yaml with no packages: key, or with packages: []Nothing is reported — pnpm includes only the root package either way
CD22turbo.json alone, or nx.json with no project.jsonNothing is reported — neither declares package patterns
CD23packages: whose entry carries a trailing colon, or a workspaces entry that is not a stringDetection aborts with workspace-manifest-malformed naming the manifest and the entry
CD24Components at . and packages/api; a Symbol in packages/api/src/orders.tsSymbol.component is api — the longest root wins, not the first that matches
CD25A Component at packages/api; a Symbol in packages/api-legacy/src/orders.tsSymbol.component is null — roots match whole path segments, not string prefixes
CD26A Component at packages/api; a Symbol in scripts/release.tsSymbol.component is null
CD27Components web and api both declaring roots: ["packages/shared"]Every Symbol under it is api, whichever order the two were declared in
CD28A Symbol the Category B / C rules dropped, in packages/apiSymbol.component is api, as it is for a kept Symbol in the same file
CD29A Component at apps/web; a Symbol whose file is spelled ./apps/web/x.tsSymbol.component is web — the file side is normalized as the root side is
CD30A Component rooted at packages//api; a Symbol in packages/api/x.tsSymbol.component is api — an empty segment names nothing on either side
CD31A Component rooted at "", at "/", or at ../vendorIt claims no file at all, the workspace root included

11. Design decisions

11.1 Why the workspace root is the outermost marker

When markers are found at multiple levels (e.g., a sub-project inside a monorepo also has a package.json), taking the inner one as root would miss the structure of the entire monorepo. Taking the outermost as root avoids unintended subset detection.

11.2 Why framework plugins are not auto-enabled

Same reasoning as config.md §15.1: rogue dependencies, unpredictable behavior, hard to trace. Detected framework names are only recorded on the Component; plugin enablement is a user decision (aburi init merely suggests candidates on the console).

11.3 Why package.json name takes top priority for id inference

The package.json name in a monorepo is an identifier deliberately chosen by a human. Directory names change easily (e.g., renaming apps → workspaces during refactoring). Prioritizing the package.json name makes Component ids resilient to path changes.

11.4 Why a single project always yields exactly 1 Component

Allowing a "zero Components" state breaks the structure of the whole IR (Symbols would have component: null). Guaranteeing at least one Component simplifies Markdown projection / diff.

11.5 Why per-language detectors are deferred

Only TS is supported today. Building multi-language detectors when no plugin other than lang-typescript exists would leave them unused. Providing the corresponding detector at the same time each language plugin is added keeps responsibilities clear.

11.6 Limits of publicApi auto-inference

The public API expressed by package.json#exports is file-granular; it cannot express fine-grained symbol-level selection (Aburi's publicApi[] accepts globs or symbol ids). Autodetect currently stops at file glob output; if symbol-level filtering is needed, users specify it manually in the config.

In the future, cross-referencing each language plugin's extractSymbols results with package.json#exports leaves room to extend to symbol-level output such as publicApi: ["ts:src/index.ts#Invoice", "ts:src/index.ts#createInvoice"].

12. Attributing Symbols to Components

Detection writes components[]; this is how the scan reads it back to fill symbols[].component (ir-schema.md §5).

The rule is over paths and nothing else:

  • A Component claims a file when one of its roots[] is the file itself or a whole-segment prefix of it. packages/api claims packages/api/src/orders.ts and does not claim packages/api-legacy/src/orders.ts
  • When several roots claim the same file, the longest one wins. Nesting is ordinary rather than exceptional: a workspace root that is a package of its own has roots: ["."] containing every other component's root (§3.1.1), so "the first root that matches" would give the whole monorepo to it
  • A file under no root at all is attributed null — the Class A value ir-schema.md §1.1 defines as "outside every Component". §5 guarantees at least one Component, not that its roots cover every file: scripts/ beside packages/* is the ordinary shape that lands here
  • Two Components may name the same root — nothing forbids it, and a config declaring one Component per framework over a shared directory produces it. The lower Component.id takes the files. Lowest rather than first, because id is unique (ir-schema.md §14 #2) and already orders components[], whereas "first" would make attribution depend on the order a config happened to list its components in
  • Both sides of the comparison — the root and the file path — go through one normalization, because they arrive from different places (a config, a filesystem walk) and normalizing one alone turns a match into a miss:
    • NFC, the form every Document string is in (ir-schema.md §1.2, enforced by §14 #19)
    • Path shape per ir-schema.md §14 #10: no leading ./, and the bare . for the workspace root
    • A trailing /, and any empty segment (packages//api), dropped. Neither is something #10 refuses — it admits both — so this is the attribution index's own repair, and it exists because a component rooted at a path holding one would otherwise hold no Symbols at all and say nothing about why
  • A root that names nothing"" or "/" — claims no files rather than the workspace root, and a root or a file holding .. names something outside the workspace and is refused. Failing closed costs a component its Symbols, which its 0 in the workspace table reports; failing open would hand it every file in the workspace and report nothing
  • A dropped Symbol is attributed exactly like a kept one. The drop is a statement about the Symbol's shape, and the per-component page lists both

What it deliberately does not do: read publicApi[], the import graph, or the manifest. Attribution answers "which package is this file in", which is what Component.roots[] already says; a Symbol re-exported by another component's barrel stays with the file that declares it.

The scan asks the question once per file rather than once per Symbol, since every Symbol from one file shares its answer. The value reaches an effect plugin as owner.component on ClassifyContext, and the call resolver's component-scope tier (§4.5 of call-resolution.md) keys on it — so attribution narrows call resolution as well as filling the per-component views. Two consequences follow from that tier, in opposite directions:

  • A qualified name that exists in two packages at once now resolves, inside the caller's own component, where the two candidates used to make both tiers ambiguous and the call resolved to nothing
  • A qualified call crossing a package boundary now falls through §4.5 to §4.6, so its edge carries low rather than medium. The tier is internal (CallEdge.confidence is not serialized), and the honest claim of the two: nothing about two packages says they are one scope

Released under the Apache License 2.0.