Commit Graph

9 Commits

Author SHA1 Message Date
Ignacy Łątka abcbce991e fix(profiler): keep all same-named components and surface platform variants (#368)
## Problem

The AST component index (`buildAstIndexWithDiagnostics`) is keyed by
component **name**. When two files define a component with the same name
— most commonly platform variants, e.g. `List` in `List.tsx` and `List`
in `List.web.tsx` — the index kept whichever file the directory walk
happened to reach **first** (`if (!index.has(name))`) and threw the rest
away.

So a lookup for `List` could return the wrong platform variant's source,
with no signal that other matches even existed. And because it depended
on filesystem walk order, *which* `List` survived was effectively
arbitrary.

Raised in review on #287.

## Fix

- **Collect every match per name** instead of keeping the first walked.
- **Choose a deterministic primary**: base files are preferred over
`.web` / `.ios` / `.android` / `.native` variants (those only override
the base when bundling for that platform), then ties break on path →
line → column. The result no longer depends on directory-walk order.
- **Surface the rest** as `otherMatches` on the index entry.
`react-profiler-component-source` now returns `otherMatches: [{ file,
line, col }]` alongside the primary, so a caller asking for `List` can
tell the locations apart instead of trusting a single arbitrary file.

`react-profiler-analyze` needs no change — it enriches findings with the
(now deterministic) primary location, which is strictly better than
before; surfacing per-finding alternates there is out of scope.

## Tests

`packages/tool-server/test/react-profiler/ast-index.test.ts` (index
layer) and a new `component-source-duplicates.test.ts` (end-to-end
through the tool's `execute`):

- same name in `List.tsx` + `List.web.tsx` → primary is the base file,
its source is returned, `List.web.tsx` is under `otherMatches`;
- only platform variants (`.android` + `.ios`, no base) → deterministic
primary by path;
- uniquely-named component → no `otherMatches`.

The two collision assertions fail on pre-fix code (no `otherMatches`,
arbitrary primary) and pass with the fix. Verified locally with Node 20:
`vitest run test/react-profiler/` — 80 passed; `tsc --build` clean;
prettier + eslint clean.
2026-06-25 21:48:16 +02:00
Ignacy Łątka fa5af59931 refactor: replace hand-rolled parsers with standard packages (#373)
## What & why

Swept the repo for hand-rolled regex/string parsers that reimplement a
solved problem, and replaced the genuinely fragile ones with
battle-tested packages (or deps already in the tree). Each swap is
paired with a regression test for an input the old code got wrong.

| Site | Was | Now |
|------|-----|-----|
| `adb.ts` `resolveAvdPath`, `linux-preflight.ts` `readMb` | per-key
anchored regex over AVD `.ini` / `config.ini` | **`ini`** |
| `workspace-reader.ts` `extractEnvKeys` | split-on-first-`=` line loop
| **`dotenv`** (`parse`) |
| `uninstall.ts` `readBundledSkillName` | `^name:(.+)$` capture +
outer-quote strip | **`yaml`** (already a dep) |
| `mcp-configs.ts` `escapeRegExp` | hand-rolled char-class escape |
**`escape-string-regexp`** |
| `export.ts` xctrace version, `session-context.ts` RN minor |
`match(/(\d+)\./)` / `split(".")[1]` | **`semver`** `coerce` (already a
dep) |
| `source-resolver.ts` `parseDebugStack` | `at fn (file:l:c)` regex
(required both line+col) | **`stacktrace-parser`** |

### Bugs these fix (covered by new tests)
- **ini**: a quoted `path = "/Users/My Name/.../X.avd"` kept its quotes,
failed the `startsWith("/")` guard → AVD unresolved. Inline comments /
trailing junk after a sizing value made the anchored regex return `null`
→ sizing warning silently lost.
- **dotenv**: `export FOO=bar` produced the key `"export FOO"` (dropped
by the identifier filter); a multi-line quoted value leaked its
continuation lines (`FAKE=...`) as phantom keys.
- **yaml**: a `name:` value with a trailing `# comment` was taken
verbatim (comment included); odd quoting only had one outer pair
stripped.
- **stacktrace-parser**: a `:line`-only frame (no column, as Hermes/JSC
can emit) became an unusable `unknown` frame.

## Deliberately left alone (not "flaky", or no safe swap)

To avoid churn and regressions, I did **not** touch:
- **`uiautomator-parser.ts`** hand-rolled XML parser — a deliberate,
documented choice with a dedicated hardening suite, including a
*leniency* test for malformed dumps (stray `</node>`). A spec XML parser
would reject inputs this is built to recover from.
- **`ios-profiler/pipeline/xml-parser.ts`** (xctrace XML) — the most
tangled parser, but it has no real `.trace` fixtures and only thin
entity-decode tests. A rewrite can't be proven equivalent without
device-generated traces, so swapping it blind risks silently breaking
iOS CPU/hang/leak reports. Best done separately with a captured fixture.
- **`http.ts` `extractHostname`** — DNS-rebinding guard; `URL.hostname`
keeps the IPv6 brackets the loopback check compares without, so the
"fix" isn't simpler and the path is security-sensitive.
- **`adb.ts` `shellQuote`** — the POSIX single-quote escape is the
standard correct form.
- **`parseAdbDevices`, `android-screen.ts` `wm size`, `detect-app.ts`
dumpsys** — tool-specific CLI-output scraping with no clean package
equivalent (and documented edge-case handling).
- **`workspace-reader.ts` `extractMetroPort`** — a best-effort heuristic
over JS config; a correct replacement needs an AST parse, out of scope
here.
- **`config-parse.ts` Yarn/npm age-gate parsing** — a precise,
unit-tested domain parser; `ms` would introduce a bare-number ambiguity.

## Verification
- `tsc --build` clean across the workspace graph.
- Tests: tool-server **1205**, installer **264**, update-core **31** —
all pass (incl. the new regression tests).
- `prettier --check` clean on all changed files.
- Production esbuild bundle (`bundle-tools.cjs`) emits a valid
`tool-server.cjs`/`mcp-server.mjs` with
`ini`/`dotenv`/`stacktrace-parser` inlined (`node --check` OK). *(The
script's final step errored only on the auth-gated
`trace-processor.wasm` download, unrelated to bundling.)*

New runtime deps: `ini`, `dotenv`, `stacktrace-parser` (tool-server),
`escape-string-regexp` (installer) — all small, pure-JS. Lockfile change
is purely additive.
2026-06-25 13:29:10 +02:00
Ignacy Łątka d987c25ad0 fix(argent): declare + ship tree-sitter so react-profiler-component-source finds components (#287)
## Problem

`react-profiler-component-source` returned `found:false` for **every**
component in a standard TS/TSX Expo project.

## Two root causes (both verified live against the Bluesky app)

1. **tree-sitter never declared/shipped.** `@swmansion/argent` didn't
depend on `tree-sitter`/`tree-sitter-typescript`, so the parser
`require()` threw, was swallowed, and the AST index was always empty.
Fixed by adding them as runtime deps and keeping them `external` in the
CJS tools bundle so the native addons are `require()`d at runtime (not
inlined). Verified: `HostingProvider`, `LoggedOutLayout`, `Login` now
resolve with correct file:line.

2. **memo()/forwardRef() components were skipped.** The AST matcher's
`variable_declarator` branch only indexed
`arrow_function`/`function_expression` value nodes; a wrapped component
(`const X = memo(...)` / `forwardRef(...)` / `React.memo(...)`) has a
`call_expression` value node, so it was never indexed — and profiler
findings are disproportionately memo-wrapped, i.e. exactly the
components users look up. Now recognises memo/forwardRef wrapper calls
(incl. `React.`-prefixed and nested `memo(forwardRef(...))`) and indexes
the declared name; `memo` sets `isMemoized`. Verified live:
`ThreadItemReadMore`, `CenteredView`, `ScrollView`, `BookmarkButton`,
`Logo` now resolve.

## Tests
`ast-index.test.ts` covers the idiomatic forms
(default-export/plain/arrow) and the memo/forwardRef/React.memo/nested
wrappers; the wrapped-form test fails on pre-fix code. Rebased onto
latest main (re-merged the `bundle-tools.cjs` helper refactor; bundle
still externalizes tree-sitter). CI green.
2026-06-18 13:20:38 +02:00
Paweł Fornagiel 5b147e9c25 fix: profiler attribution on older React DevTools backends (#262)
## Summary
- Resolve profiler display-name lookup across both
`getDisplayNameForElementID` and legacy `getDisplayNameForFiberID`
backends.
- Add a fallback diagnostic (`displayNameApiAvailable` /
`unattributed_reason`) so fully-unattributed captures are reported as an
API-surface mismatch instead of a transient lifecycle race.
- Extend multi-renderer script coverage for modern, legacy, mixed, and
missing display-name accessor scenarios.
2026-05-27 10:57:45 +02:00
Ignacy Łątka 2180dd8e6a ci: typecheck test files in CI (#202)
We've had lots of type errors in our tests, scripts and other non-core
code despite all CI being green. This PR fixes that.

This PR also removes some redundant features like `@ts-check` inline
directives which have been replaced by proper ts config files.

<details>

## AI Summary

Audit of every CI workflow against every package's `package.json` and
`tsconfig` surfaced several gaps where CI did partial coverage. This PR
closes them and fixes every pre-existing type error the new gates
surfaced.

## Gaps closed

| # | Gap | Fix |
|---|---|---|
| 1 | `tsc --build` only covers `src/**`; test files (`test/**`,
`tests/**`) never typecheck. Vitest transforms tests with esbuild —
strips types instead of checking them. | New `tsconfig.test.json` per
test-bearing package (`composite: false`, `noEmit`, `rootDir: "."`,
includes `src/**` + `test/**` (or `tests/**`) + `vitest.config.ts`). New
`typecheck:tests` npm script per package. New CI step `npm run
typecheck:tests --workspaces --if-present`. |
| 2 | Build/publish scripts (`scripts/*.{cjs,mjs}`,
`packages/argent/scripts/*.cjs`, `packages/skills/scripts/install.js`) —
JS, never typechecked. `sync-readme.cjs` runs in `prepack`;
`postinstall.cjs` runs on every install; `bundle-tools.cjs` is
build-critical. A typo only surfaces at runtime. | Root
`tsconfig.scripts.json` with `allowJs` + `checkJs` + `noImplicitReturns`
covers all script paths. Root `typecheck:scripts` npm script. New CI
step. Per-file `// @ts-check` comments removed — tsconfig drives
inclusion. |
| 3 | `argent-cli` had no `typecheck:tests` script. When tests get added
there they'd silently bypass the gate. | Added `tsconfig.test.json` +
`typecheck:tests` script. `--if-present` would have skipped it anyway,
so this is future-proofing. |
| 4 | Local `prettier --check .` walked into the `argent-private`
submodule and reported violations there. CI didn't see this because
`actions/checkout@v4` doesn't init submodules — so local `prettier
--check` did not match CI behavior. | New `.prettierignore` excludes the
submodule plus `dist/`, `node_modules/`, lock files, `tsbuildinfo`.
No-op in CI; aligns local with remote. |

## Test type errors fixed

These were already present. The `npm test` passed because esbuild
stripped the offending types:

- **argent-installer**: widen `scope` literal type for
dead-code-elimination guard; non-null `addAllowlist!`/`removeAllowlist!`
(optional methods on adapter).
- **argent-mcp**: add `.js` extensions for NodeNext module resolution.
- **registry**: broaden `StaticBlueprintResult` blueprint api from `{
id; deps? }` to `Record<string, unknown>` so existing tests can pass
arbitrary api shapes.
- **tool-server**:
- non-null `zodSchema!` on tool defs that always carry one
(`launchAppTool`, `restartAppTool`, etc.)
- cast sentinel `"ignored"` strings to `DeviceInfo` in factory-rejection
tests
- match the 4-arg `dispatchByPlatform<IosServices, AndroidServices,
Params, Result>` signature (tests still passed the old 3-arg shape)
- `assertFlowRunResult` type guard for the `FlowRunResult |
FlowPrerequisiteNotice` union before `.steps` access
- swap broken `typeof import("supertest").default` for static `import
supertest` (supertest is `export = supertest`)
- fill in the 4 fields missing from a `NativeProfilerSessionApi` mock
(`xctraceProcess`, `recordingTimedOut`, `recordingExitedUnexpectedly`,
`lastExitInfo`)
- cast a vitest `Mock` to a plain function at one direct call site where
the `Procedure | Constructable` union loses callability
- fix `update-checker.test.ts` reading `../../package.json` (off-by-one
— went past tool-server; was relying on a Vite resolver quirk)
- replace one cross-package `../../../registry/src/index` import with
`@argent/registry` so registry's private `services` field isn't compared
between src and dist declarations

All fixes are type-level. Full `npm test --workspaces --if-present`
still reports **991 passed across 87 test files**.

</details>
2026-05-20 17:03:34 +02:00
Paweł Fornagiel 8a3b5a1ff7 fix(react-profiler): self-bootstrap DevTools backend when no client is attached (#205)
## Summary

`react-profiler-start` failed in any RN bridgeless dev build with no
external React DevTools client (Fusebox React tab / `npx
react-devtools`) connected — returning the misleading error *"No React
renderer interface attached yet. Wait for the app to render its first
commit and retry."* even when the app had rendered. This PR makes the
tool self-bootstrap the DevTools backend so profiling works out of the
box, and replaces the one-size-fits-all error with mode-specific
actionable messages.

Companion to #199 (schema fix). With both landed, `react-profiler-start`
is end-to-end usable from MCP clients against a vanilla RN 0.76 dev app
with zero extra setup.

> [!IMPORTANT]
> The bootstrap path mutates global runtime state (Bridge + Agent + hook
subscriptions via `connectWithCustomMessagingProtocol`). Unit tests
cover every reason branch, but the live behavior — especially
coexistence with a later-attached `npx react-devtools` client and the
absence of subscription leaks across repeated start/stop cycles — must
be **smoke-tested against a working RN application** before this merges.

## Root cause

argent's profiler delegates React commit capture to the in-app DevTools
backend via `rendererInterface.startProfiling(...)`. The two maps on
`__REACT_DEVTOOLS_GLOBAL_HOOK__` look similar but are populated by
different actors:

| Map | Populated by | When |
|---|---|---|
| `hook.renderers` | React itself via `hook.inject(renderer)` | First
render |
| `hook.rendererInterfaces` | DevTools backend's `attach(hook, id,
renderer, global)` (in `react-devtools-shared/src/backend/renderer.js`)
| When `initBackend(hook, agent, global)` runs |

In an RN 0.76 bridgeless dev build, `setUpReactDevTools.js` runs at
bundle load and calls `react-devtools-core.connectToDevTools({ host:
'localhost', port: 8097, websocket })`. That opens a WebSocket;
`initBackend` only runs on `ws.onopen`. With no DevTools client
listening on 8097, the connection never opens, `initBackend` never runs,
`rendererInterfaces` stays empty — but `hook.renderers` is populated
because React doesn't care about the backend.

So `rendererInterfaces.size === 0` while `renderers.size === 2` is the
steady-state of a dev build with no DevTools client. argent's pre-flight
check saw the empty map and threw with a misleading message ("wait for
first commit") that conflated three distinct failure modes:
1. App hasn't rendered yet (legitimate wait).
2. App rendered but no DevTools backend attached (this PR — the common
case).
3. Production build / no `react-devtools-core` in bundle (different
recovery).

Direct CDP verification of the hook from the bug state:
```js
{ rIds: [1, 2], riIds: [], reactDevtoolsAgent: undefined }
```

## Fix

When the pre-flight detects `rendererInterfaceFound: false` with the
hook present, `react-profiler-start` now runs a self-bootstrap script
(`BOOTSTRAP_DEVTOOLS_BACKEND_SCRIPT`) that:

1. **Looks up `react-devtools-core` in the Metro module registry** by
scanning `__r.getModules()` entries for a `verboseName` matching
`react-devtools-core/dist/backend`. Handles both `Map<id, meta>` (modern
Metro) and array-of-meta (older Metro) shapes — mirrors the pattern in
`debugger-component-tree`.

2. **Calls `connectWithCustomMessagingProtocol`** (rdt-core ≥5.1) with
no-op `onSubscribe` / `onUnsubscribe` / `onMessage` handlers. The
function internally constructs a `Bridge` + `Agent` and runs
`initBackend(hook, agent, window)`, which iterates `hook.renderers` and
calls `attach()` on each — exactly the call path that populates
`rendererInterfaces`. The fake wall discards every outbound message,
which is fine because argent calls into the rendererInterface directly
and never reads from the bridge.

3. **Returns one of seven distinct reasons**, each mapping (via
`bootstrapFailureMessage` in
`utils/react-profiler/devtools-bootstrap.ts`) to its own actionable
error message:
   - `no-hook` → "production build" guidance
   - `no-renderers` → "wait for first commit"
   - `no-metro-modules` → bundle isn't Metro-style
- `no-rdt-module` → react-devtools-core stripped from bundle
(production)
- `unsupported-rdt-version` → rdt-core <5.1 (RN <0.74) → fall back to
`npx react-devtools`
- `metro-scan-error` / `bootstrap-threw` / `bootstrap-no-effect` →
unexpected, with verbatim error text

After a successful bootstrap, `REACT_NATIVE_PROFILER_SETUP_SCRIPT` is
**re-run** to install argent's `__argent_startWrapped__` wrappers on the
freshly-attached interfaces. Without this re-run, `buildStartScript`'s
post-start `__argent_isProfiling__` check would falsely report
`startProfiling-threw` (the wrapper sets the flag; an unwrapped
interface leaves it false even on a successful start).

### Idempotency / leak protection

`initBackend` adds subscriptions to `hook.sub('renderer', ...)` on every
call, so naive re-invocation would leak listeners. The script
short-circuits with `reason: 'already-attached'` when
`rendererInterfaces.size > 0`, so repeated `react-profiler-start` calls
within a session never re-bootstrap. Verified empirically: listener
counts stay flat across multiple start/stop cycles.

### Coexistence with external DevTools

If `npx react-devtools` is later started while argent has already
bootstrapped, RN's bundled reconnect path calls `connectToDevTools`
again, which goes through `attachRenderer` → sees `rendererInterface !=
null` (argent's already there) → re-emits `renderer-attached` without
re-attaching. Both backends coexist; argent talks to the renderer
interface directly, so the active "agent" is irrelevant to profiling.

## Files changed

- `packages/tool-server/src/utils/react-profiler/scripts.ts` — adds
`BOOTSTRAP_DEVTOOLS_BACKEND_SCRIPT` (the injected IIFE that does the
Metro scan + connect call).
- `packages/tool-server/src/utils/react-profiler/devtools-bootstrap.ts`
*(new)* — `BootstrapResult` type, `BootstrapReason` union, and the
`bootstrapFailureMessage()` helper that maps each reason to its
agent-facing error. Mirrors the `session-ownership.ts` convention (types
+ pure helpers as a sibling of `scripts.ts`, unit-testable without CDP /
vitest mocking).
-
`packages/tool-server/src/tools/profiler/react/react-profiler-start.ts`
— calls bootstrap on empty `rendererInterfaces`, re-runs setup, throws
via the imported `bootstrapFailureMessage`. Also replaces two
pre-existing error strings ("hook not present", `how_to_reclaim`) with
the same agent-friendly phrasing used elsewhere in this PR.
-
`packages/tool-server/test/react-profiler/bootstrap-devtools-backend.test.ts`
*(new)* — 12 unit tests covering every reason branch (Map + array Metro
registries, require() throws, getModules throws, connect throws, connect
is a no-op, idempotent already-attached path).

## Manual verification (iPhone Air, RN 0.76.7 bridgeless, no external
DevTools)

1. Reload bundle → confirm `rIds: [1,2], riIds: []` (bug state).
2. Pre-fix: `react-profiler-start` returns *"No React renderer interface
attached yet…"*
3. Post-fix: `react-profiler-start` returns `{ started_at,
hermes_version: "for RN 0.76.7", detected_architecture: "bridgeless" }`.
Hook now shows `riIds: [1,2]`, `__argent_startWrapped__: true` on each,
`reactDevtoolsAgent` set, `__argent_isProfiling__: true`.
4. Forced a React state-hook dispatch during the session and stopped:
`total_react_commits: 2`, `unattributed_ms: 8.41` — real commit data
captured through the bootstrapped backend.
5. Second `react-profiler-start` in same session returns success via
`already-attached` early-exit (verified directly against the script).
Hook listener counts (`renderer`, `renderer-attached`, `react-devtools`)
stable across multiple cycles — no subscription leak.
6. Simulated `no-renderers` by clearing `h.renderers` → got *"React has
not rendered yet, so there is nothing to profile. Ask the user to wait
until the app shows React content…"*
2026-05-15 16:52:56 +02:00
Paweł Fornagiel 62d0fe0c73 refactor: overhaul React profiler - DevTools backend, session ownership, richer data (#156)
## What this PR does

This refactor:
- replaces the DFS fiber-walking hook inside the app's JS runtime with
the **React DevTools backend** as the source of profiling data
- adds a session-ownership system so two tool-server instances can't
silently clobber each other
- gives agent a new status tool for recovery after interruptions
- improves what the profiling analysis report shows the agent

I realise this a big pull request, but changes began to affect each
other when it comes to registered scripts on the react-native side.
This, all-in-all, should however cover our needs when it comes to the
react profiler and if it proves sufficient - we will only have to
iterate on small fixes afterwards.

If I were you, I would skip over the implementation of scripts on the
native-side and focus on what the tool-server does when it comes to
profiling right now.

---

## Profiling workflow

The overall flow has not changed from the agent's perspective - agent
calls `react-profiler-start`, do something in the app, call
`react-profiler-stop`.

What changed is how the profiling data is collected.

**Before:** `react-profiler-start` injected a custom `onCommitFiberRoot`
hook that walked the live fiber tree at every React commit in DFS-like
way and appended records to a global `__ARGENT_DEVTOOLS_COMMITS__` array
in the app's JS heap. On stop, the array was fetched in chunks over CDP.

**Now:** `react-profiler-start` calls `ri.startProfiling(true)` on the
React DevTools backend's `rendererInterface`. React's own profiler
accumulates commit data internally. On stop, a single
`STOP_AND_READ_SCRIPT` call stops the backend and reads
`ri.getProfilingData()` in one round-trip. The DevTools backend data is
richer, more accurate, and doesn't require maintaining a bespoke
fiber-walking implementation.

---

## Session ownership

The profiler runs inside the app's JS heap. There's only one "slot": it
used to be that when two tool-server instances (or the same instance
after a process restart) both try to start profiling, the second call
silently overwrites the first and all data from the first session is
gone.

Right now, there is an approach which blocks silent overtake of the
profiling session.

When `react-profiler-start` runs, it:

1. Reads the current state from the runtime using `READ_STATE_SCRIPT` (a
pure, side-effect-free read).

2. If a session is already running, it reads the
`__ARGENT_PROFILER_OWNER__` object written into `globalThis`, which
contains `sessionId`, `startedAtEpochMs`, and `lastHeartbeatEpochMs`.

3. Classifies the existing session as **fresh** or **stale** using
`classifyStaleness()` (stale = no heartbeat for > 5 minutes).

If the session is fresh and you don't pass `{ force: true }`, start
refuses and returns an `already_running` payload instead of overwriting.
 
4. If the session is stale or `force: true`, it calls
`STOP_FOR_TAKEOVER_SCRIPT` to cleanly stop the prior session, then
starts a new one.

### Heartbeats

Each `sessionId` UUID is written to `__ARGENT_PROFILER_OWNER__` in the
runtime at start. While profiling, any side query tool
(`react-profiler-renders`, `react-profiler-fiber-tree`) that this same
tool-server process calls will bump `lastHeartbeatEpochMs` via
`HEARTBEAT_SCRIPT`. This keeps the session from being classified as
stale as long as the owning process is still making calls.

### What the agent sees when something goes wrong

Previously, if you called `react-profiler-start` into an already-running
session you'd get a silent clobber or a confusing Hermes error. Now:

- **Running session detected, still fresh:** `{ already_running: true,
owner: { sessionId, startedAtEpochMs, lastHeartbeatEpochMs },
age_seconds, how_to_reclaim }` — the agent can read the owner timestamp
and decide whether to tell the user to wait or force-reclaim.
- **Running session detected, stale:** automatic takeover with a log
note.
- **No owner record (e.g. another DevTools client started the
session):** also automatic takeover.

### New `react-profiler-status` tool

A read-only tool that reports the current state without starting or
stopping anything. Returns `session_status` as one of `"active"` /
`"taken_over"` / `"stopped"` / `"no_react_runtime"`, plus the full owner
record. Use this after a debugger disconnect, an unexpected error, or
when the agent restarts mid-session to decide whether to continue,
reclaim, or give up.

The agent has instructions to use the tool whenever it is interrupted
during the profiling by the user or otherwise. Will have to look into
how much this is actually used.

---

## What changed in the data we collect

The old hook read fiber fields directly (`fiber.type.displayName`,
`fiber.memoizedProps`, `fiber.selfBaseDuration`, etc.) at commit time.
This meant:

- Names for **transient components** (modals, tooltips, navigation
screens) that unmounted before stop were already lost - the fiber was
gone.
- Change-detection logic (which props changed, which hooks fired) was
reimplemented manually against the fiber internals.

The new approach reads `ri.getProfilingData()` which gives us the
DevTools backend's own commit log. Each commit entry has
`fiberActualDurations`, `fiberSelfDurations`, `changeDescriptions`,
`timestamp`, and `duration` - the same data React DevTools Profiler
shows in its browser panel.

### Fiber name cache for transient components

To address the lost-name problem, `FIBER_ROOT_TRACKER_SCRIPT` now hooks
`onCommitFiberRoot` and, immediately after the DevTools backend finishes
processing each commit (synchronously, while the fiber is still in
memory), populates `globalThis.__argent_fiberNames__` with `fiberID →
displayName` entries. Because fiber IDs are monotonically increasing and
never reused, this cache never goes stale. `STOP_AND_READ_SCRIPT` uses
this cache as a fallback for any fiber whose name
`getDisplayNameForElementID` can no longer resolve.

### Unattributed fiber tracking

When a transient fiber renders during the session but still can't be
named at stop time (not in the name cache, not in the DevTools instance
map), its work is now **surfaced rather than silently dropped**. The
stop tool computes `unattributedByCommit`: a per-commit tuple of
`[commitIndex, droppedFiberCount, droppedSelfMs]`. This flows through
to:

- The session metadata file on disk (`unattributedByCommit` array).
- The pipeline (`buildHotCommitSummaries` receives and indexes it).
- The `HotCommitSummary` type gains `unattributedMs` and
`unattributedFiberCount`.
- The generated report shows a warning line per commit with unattributed
work, e.g. `⚠️ 12ms unattributed — 3 fibers unmounted before stop`.
- `react-profiler-stop` returns top-level `unattributed_ms`,
`unattributed_fiber_count`, `unattributed_commit_count` in its response
so the agent immediately knows if there's a gap.

### Actual vs. self duration now both surfaced

The old report showed only `selfDuration` (exclusive render time). Both
are now captured in `HotCommitComponentEntry` and rendered in the report
as:

```
- `ComponentName` — 4.2ms self, 18.7ms w/children
```

A legend at the top of the report explains when to sum self and when not
to sum inclusive time. The summary table also shows both columns.

### Component name unwrapping

Display names from the DevTools backend can be wrapped:
`Forget(Memo(MyButton))`, `ForwardRef(Icon)`, etc. The pipeline's render
stage now strips these wrappers using `annotateComponentName()` and
appends a human-readable tag, so the report shows `\`MyButton\`
[React.memo + React Compiler]` instead of the raw internal string. The
raw DevTools name is still used in all tool-call suggestions (since
every pipeline stage keys on the original string).

---

## What scripts do what

All injected JS scripts now live in a single file —
`packages/tool-server/src/utils/react-profiler/scripts.ts` — grouped by
responsibility:

| Script | When injected | What it does |
|---|---|---|
| `REACT_NATIVE_PROFILER_SETUP_SCRIPT` | Every `react-profiler-start`
(idempotent) | Wraps `ri.startProfiling` / `ri.stopProfiling` to track
`isProfiling` state and `startedAtEpochMs` without clock skew; installs
the `__argent_profilerHeartbeat` helper |
| `FIBER_ROOT_TRACKER_SCRIPT` | On CDP connect (idempotent) | Intercepts
`onCommitFiberRoot` to populate the `__argent_fiberNames__` commit-time
name cache |
| `READ_STATE_SCRIPT` | Every `react-profiler-start`,
`react-profiler-status` | Pure read: returns `{ hookExists,
rendererInterfaceFound, isRunning, owner, nowEpochMs }` |
| `buildStartScript(ownerJson)` | `react-profiler-start` on the happy
path | Calls `ri.startProfiling`, writes owner record, returns
post-start verification flags |
| `STOP_FOR_TAKEOVER_SCRIPT` | `react-profiler-start` on the takeover
path | Stops the prior session cleanly before starting a new one |
| `HEARTBEAT_SCRIPT` | `react-profiler-renders`,
`react-profiler-fiber-tree` | Bumps `lastHeartbeatEpochMs` while the
owning process is still active |
| `STOP_AND_READ_SCRIPT` | `react-profiler-stop` | Stops the backend
profiler and reads `getProfilingData()` + name cache in one round-trip |
| `RESOLVE_FIBER_META_SCRIPT` | `react-profiler-stop` (if commits > 0) |
Walks the live fiber tree to collect `hookTypes`, `parentName`, and
`isCompilerOptimized` metadata per component |
2026-04-29 19:20:50 +02:00
Filip131311 8c2a313c76 chore: format codebase with Prettier
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 14:53:58 +02:00
Ignacy Łątka cd41fa4d3b fix: write profiler output to /tmp instead of project root (#73)
* fix: write profiler output to /tmp instead of project root

The profiler was creating an argent-profiler-cwd/ directory inside the
user's project root. Output is now written to /tmp/argent-profiler-cwd/
using os.tmpdir() so no artifacts are left in the project tree.

* fix: remove project_root from profiler tools, drop getDebugDir param

No backwards compatibility needed — project_root is stripped from
ios-profiler-start and profiler-load schemas entirely, and getDebugDir
takes no arguments now that the path is always /tmp/argent-profiler-cwd/.

* fix: remove stale project_root from skill docs, add dump.test.ts

- SKILL.md and diagnostic-tools.md still referenced project_root for
  profiler-load after it was removed from the schema — now corrected
- Add 17 tests covering getDebugDir, writeDump, writeDumpCompact,
  readCpuProfile, and readCommitTree (all passing)
2026-04-03 14:23:38 +02:00