ADR-0034.
`runward characterize` sees the whole tree — nested workspaces — and orders every scan deterministically
Date: 2026-07-20
Status: proposed
Deciders: Thibault Souris (maintainer)
Method: decision-loop — the resume-existing audit's #1 finding, reality-checked against src/lib/characterize.ts (root-only detectors) and the ADR-0014 read-only/deterministic/zero-LLM contract.
Context
characterize is the read-only entry to a brownfield mission. Two defects, observed in the real code:
Root-only blindness.
detectEcosystems,detectEntrypoints,detectCI,detectContainers(characterize.ts:61–149) all test onlyjoin(root, …). A monorepo — pnpm/yarn/npm workspaces, a Turbo/Nx repo, a Cargo workspace,go.work, a multi-module Gradle build — carries its real manifests underpackages/*,apps/*,services/*. On the dominant shape of large brownfields, theLanguages & buildtable comes back empty or wrong, and the modeM2 — join a project in flight(the audit's typical target) starts from nothing. The boundedwalk()(characterize.ts:39) already exists and already skips heavy dirs, but only feeds a file count (:178) and the test count (:155) — it never discovers manifests.Non-deterministic ordering.
detectCI(characterize.ts:135) pushesreaddirSync(wf)results unsorted straight into the renderedcharacterization.md.readdirSyncorder is filesystem-dependent, so two runs on the same commit — on the same machine after a re-checkout, or on a colleague's clone — can render the CI list in a different order. That is a direct breach of the ADR-0014 core promise ("two runs on the same commit agree"), on the command whose whole value is reproducibility.
Decision
characterize discovers manifests across the bounded tree, reports workspaces as a first-class section, and sorts every filesystem-derived list before it is rendered.
- Nested-manifest discovery. Reuse
walk()(bounded depth,SKIP_DIRS-pruned, read-only) to find dependency manifests below the root. The root ecosystem detection is unchanged; nested finds are reported in a new "Sub-packages / workspaces" section aspath → ecosystemfacts (bounded, deterministic). Depth is capped (default 3) so a pathological tree cannot explode the scan; when the cap or a result cap is hit, the output says so (never a silent truncation). - Workspace markers. Detect and report the presence (pure existence, no parsing of intent) of
pnpm-workspace.yaml,turbo.json,nx.json,go.work, aworkspacesfield in the rootpackage.json, a[workspace]table in rootCargo.toml, andsettings.gradle/settings.gradle.kts. Facts only — "this repo declares a workspace", never "this is well-structured". - Deterministic ordering. Every
readdirSyncresult that reaches the rendered output is.sort()-ed. Nested manifests render in a stable, path-sorted order. This closes the reproducibility hole and is the precondition the determinism test (ADR-0014 follow-up) will assert.
Still confidence: high facts, read-only, zero-LLM. No manifest is executed; nested manifests are recorded as path → ecosystem presence facts only — their dependency lists are deliberately not parsed (the at-rest dependency parse applies to root manifests; resolving the nested graph is the operator's reconstruction, per the discarded alternative below).
Alternatives discarded
- Unbounded recursion to find every manifest. Rejected: unbounded walk on a large legacy repo is a latency and memory risk, and violates the "bounded, read-only" posture. Depth cap +
SKIP_DIRS(already thewalk()contract) is the deterministic, safe reuse. - Parse workspace globs and resolve the true package graph. Rejected for now: resolving
workspaces: ["packages/*"]into an accurate dependency graph edges toward interpretation and per-tool semantics (pnpm ≠ Cargo ≠ go.work). Presence + discovered nested manifests are honest facts; the graph is a judgement the operator/agent reconstructs (ADR-0013). - Leave ordering to the filesystem and "usually it's stable". Rejected: "usually deterministic" is non-deterministic. The contract is byte-for-byte agreement; a sort is one line and removes all doubt.
Consequences
- Positive. The dominant brownfield shape (monorepo) is no longer invisible;
M2starts from a real map. Reproducibility becomes true, not approximately true — the determinism test can now assert byte-equality.walk()earns its keep beyond a file counter. - Negative, accepted. A deeper (still bounded) scan costs marginally more I/O on huge trees; capped and reported. The workspace section can be long on a big monorepo; it is sorted and cap-reported.
- On other boundaries.
Inventorygrows asubPackages/workspaceMarkersshape;renderCharacterizationgrows one section;--mine(ADR-0038) can later consider nested stacks. The gate is untouched (characterizesits upstream).
Reevaluation trigger (mandatory, dated)
Reopen if (a) real repos show the depth-3 cap misses common layouts (e.g. deeply nested services/*/*/package.json) — then raise the cap or make it a flag; (b) the flat "discovered nested manifests" list proves insufficient and operators need the resolved workspace graph — then reconsider parsing globs behind the same read-only contract; or (c) the sub-package scan measurably slows characterize on large monorepos beyond an acceptable budget.
Trigger set on: 2026-07-20 · Watched via: dogfooding characterize on monorepos and the determinism test's stability.
References
- ADR-0014 — the read-only/deterministic/zero-LLM contract this extends and whose "two runs agree" promise the sort restores.
- ADR-0013 — facts vs the operator-reconstructed why (why the workspace graph stays out).
src/lib/characterize.ts(walk,detectEcosystems,detectCI,renderCharacterization) — edited.- The resume-existing / brownfield audit (2026-07-20).