# Usage guide Walkthrough of how `memorydetective` actually works in practice. What each tool returns, how fixes flow from diagnosis to your codebase, and the architecture decision behind splitting "diagnose" from "edit". For a quick API reference, see the [`README.md`](./README.md). For the full changelog, see [`CHANGELOG.md`](./CHANGELOG.md). --- ## 1. Three ways to use it ### 1a. CLI mode. Quickest way to see it work ```bash npm install -g memorydetective memorydetective --version # 1.0.0 # Run analyze on any .memgraph file memorydetective analyze ~/Desktop/myapp.memgraph ``` What you see (terminal output, ANSI-coloured): ``` ┌─ memorydetective analyze ──────────────────────────────────────┐ │ Path: /Users/.../myapp.memgraph │ Process: MyApp (pid 12345) │ Bundle: com.example.myapp └────────────────────────────────────────────────────────────────┘ 60,436 leaks (7.89 MB) 4 ROOT CYCLE blocks Top cycle: Swift._DictionaryStorage Diagnose `~/Desktop/myapp.memgraph` and find where to fix in this codebase. Claude orchestrates the full flow (see [section 3](#3-how-fixes-actually-flow-from-diagnosis-to-edit)). --- ## 2. The 34 cycle patterns and their fix hints `classifyCycle` ships with a built-in catalog of 34 common iOS retain-cycle patterns. Each pattern returns: - a textual `fixHint` (one-line plain-English direction) - a `staticAnalysisHint` (which SwiftLint rule complements the runtime evidence. Or an explicit gap notice) - a `fixTemplate` (Swift before/after code snippet. New in v1.7) the agent can adapt directly Patterns are grouped below by the framework / source they target. ### v1.0 core (8). SwiftUI + Combine + Concurrency + Notifications | Pattern ID | When it matches | Fix hint (summary) | |---|---|---| | `swiftui.tag-index-projection` | `TagIndexProjection` appears in chain (`.tag()` modifier capturing self) | Replace `[weak self]` capture with a static helper, or weak-capture the coordinator/view-model directly. | | `swiftui.dictstorage-weakbox-cycle` | Root is `_DictionaryStorage<…WeakBox>` | SwiftUI internal observation graph cycle. Find your app-level types in the chain and break the strong capture there. | | `swiftui.foreach-state-tap` | `SwiftUI.ForEachState` in chain | ForEachState held by a tap-gesture closure capturing `self`. Make the tap handler a static function or capture properties weakly. | | `closure.viewmodel-wrapped-strong` | `__strong` edge with `_viewModel.wrappedValue` in label | Closure captures `_viewModel.wrappedValue` strongly. Capture the underlying ObservableObject weakly: `[weak vm = _viewModel.wrappedValue]`. | | `viewcontroller.uinavigationcontroller-host` | `UINavigationController` + `UIHostingController` both in chain | Clear `viewControllers = []` in `dismantleUIViewController` to break the host->VC->host cycle. | | `combine.sink-store-self-capture` | `AnyCancellable` + `Closure context` | `.sink { self.x = … }` keeps self alive through the AnyCancellable that's stored on self. Capture explicitly: `.sink { [weak self] in self?.x = … }`. | | `concurrency.task-without-weak-self` | `_Concurrency.Task<…>` + `Closure context` | `Task { }` body strongly captures self for the lifetime of the task. `Task { [weak self] in guard let self else { return }; … }`. | | `notificationcenter.observer-strong` | `NotificationCenter` / `NSNotificationCenter` + `Closure context` | Block-form `addObserver(forName:...)` keeps the block alive in the center. Use `[weak self]` in the block, or store the returned `NSObjectProtocol` and call `removeObserver(_:)` in `deinit`. | ### v1.4 expansion (16). UIKit, Combine, Concurrency, SwiftUI, WebKit, RxSwift, Realm | Pattern ID | When it matches | Fix hint (summary) | |---|---|---| | `timer.scheduled-target-strong` | `__NSCFTimer` / `NSTimer` in chain | `Timer.scheduledTimer(target:selector:)` retains its target. Use the closure form with `[weak self]` and `invalidate()` in `deinit`. | | `displaylink.target-strong` | `CADisplayLink` in chain | `CADisplayLink(target:selector:)` retains its target. Wrap with a `WeakProxy` and `invalidate()` in `deinit`. | | `gesture.target-strong` | `UIGestureRecognizer` / `UIControl` in chain | `addTarget(_:action:)` is strong by default. Prefer `UIAction` (iOS 14+) or `removeTarget(...)` in `deinit`. | | `kvo.observation-not-invalidated` | `NSKeyValueObservation` in chain | `obj.observe(\.x) { ... }` retains its handler. `[weak self]` inside, `token.invalidate()` in `deinit`. | | `urlsession.delegate-strong` | `__NSURLSessionLocal` / `NSURLSession` in chain | `URLSession(configuration:delegate:)` retains its delegate strongly (Apple-documented). Call `invalidateAndCancel()` in `deinit`. | | `dispatch.source-event-handler-self` | `OS_dispatch_source` / `DispatchSource` in chain | `setEventHandler { ... }` retains the closure. Use `[weak self]` and clear with `setEventHandler {}` in `deinit`. | | `notificationcenter.observer-not-removed` | `NotificationCenter` + `NSObjectProtocol` | Block-form observer never deregistered. Call `removeObserver(_:)` in `deinit` or use the selector form. | | `delegate.strong-reference` | Class with `Delegate` suffix in chain | `var delegate: ...?` declared without `weak`. Mark `weak`, or refactor to closure-based callback. | | `swiftui.envobject-back-reference` | `EnvironmentObjectStorage` + UIKit interop class in chain | `@EnvironmentObject` with strong back-reference to `UIView`/`UIViewController`. Wrap UIKit refs in `weak` box. | | `combine.assign-to-self` | `Combine.Assign` / `Subscribers.Assign` in chain | `.assign(to: \.x, on: self)` retains self. Use `.assign(to: &$published)` or `.sink { [weak self] ... }`. | | `concurrency.task-mainactor-view` | `_Concurrency.Task<…>` + SwiftUI View signal | `Task { await self.foo() }` inside `View.body` retains storage. Use `.task { ... }` modifier or capture properties up front. | | `concurrency.asyncstream-continuation-self` | `AsyncStream` + `Closure context` in chain | Continuation retains `onTermination`/producer closures. `[weak self]` inside, `task.cancel()` in `deinit`/`onDisappear`. | | `webkit.scriptmessage-handler-strong` | `WKUserContentController` / `WKScriptMessageHandler` / `WKWebView` | `add(_:name:)` retains the handler. Wrap in `WeakScriptMessageHandler` proxy or call `removeScriptMessageHandler(forName:)`. | | `coordinator.parent-strong-back-reference` | Two `*Coordinator` nodes in cycle | Child holds parent without `weak`. `weak var parentCoordinator`, `removeAll { $0 === finishedChild }` on completion. | | `rxswift.disposebag-self-cycle` | `RxSwift.DisposeBag` / `RxSwift.AnonymousDisposable` in chain | Subscription retains self if `[weak self]` is omitted or unbound method ref is passed. Always use `[weak self]`. | | `realm.notificationtoken-retained` | `RealmSwift.NotificationToken` / `RLMNotificationToken` | `Results.observe { ... }` retains the closure. `[weak self]` inside, `token?.invalidate()` in `deinit`. | ### v1.5 catalog completion (3). Core Animation + Core Data | Pattern ID | When it matches | Fix hint (summary) | |---|---|---| | `coreanimation.animation-delegate-strong` | `CABasicAnimation` / `CAKeyframeAnimation` / `CASpringAnimation` / `CAAnimationGroup` / `CATransition` in chain | `CAAnimation.delegate` is **strong** (Apple-documented quirk). Use a `WeakProxy` delegate or `anim.delegate = nil` in `deinit`. | | `coreanimation.layer-delegate-cycle` | Custom `CALayer` subclass (`CAShapeLayer` / `CAGradientLayer` / `CAEmitterLayer` / `CAMetalLayer` / etc.) in chain without `UIView` auto-pairing | Custom layer wired to non-UIView delegate leaks. Wrap in `WeakLayerDelegate` or clear `layer.delegate = nil` in `deinit`. | | `coredata.fetchedresultscontroller-delegate` | `NSFetchedResultsController` / `_PFFetchedResultsController` in chain | Apple's historical strong-delegate quirk via the change-tracker. `frc.delegate = nil` in `viewWillDisappear` / `deinit`. | ### v1.6 catalog expansion (6). Swift 6 / Observation / SwiftData / NavigationStack era Sourced from Apple Developer Forums (#736110, #716804, #748042), Swift Forums (#64584, #77257), Donny Wals on the Swift 6.2 `Observations` API, and the Embrace WKWebView memory-leak writeup. | Pattern ID | When it matches | Fix hint (summary) | |---|---|---| | `swiftui.observable-state-modal-leak` | `_$ObservationRegistrar` + sheet/presentation host in chain | `@Observable` model passed as `@State` to a modal leaks. Move the model to `@StateObject` on the parent and pass via `@Bindable`, or use `.sheet(item:)` with a value type. | | `swiftui.navigationpath-stored-in-viewmodel` | `NavigationPath` / `NavigationStackStore` / `AnyHashableStorageBase` in chain | NavigationPath retains every element ever pushed (FB11643551, unfixed). Keep it `@State` local to the view, or reset with `path = NavigationPath()` after `popToRoot`. | | `concurrency.async-sequence-on-self` | `AsyncSequence` / `AsyncIteratorProtocol` + `Task<...>` in chain | `for await ... in seq { use(self) }` pins self via the iteration context. `[weak self]` does NOT help. Capture only the values needed before the loop, or `task.cancel()` in `deinit`. | | `concurrency.notificationcenter-async-observer-task` | `NotificationCenter.Notifications` (the `AsyncSequence` form) + `Task<...>` | Special case of the above. `for await _ in NotificationCenter.default.notifications(named:)` never terminates. Same fix discipline. | | `swiftui.observations-closure-strong-self` | `Observations` (the Swift 6.2 API, NOT `ObservationRegistrar`) + `Closure context` | The new non-SwiftUI `Observations { }` closure retains self like `Combine.sink`. Use `[weak self]` inside the closure. | | `webkit.wkscriptmessagehandler-bridge` | `WKWebView` + `WKUserContentController` + `WKScriptMessageHandler` (or `*Bridge`/`*Handler` class) all in chain | The 3-link bridge cycle: bridge → webView → contentController → bridge. Wrap the handler in `WeakScriptMessageHandler` proxy, or `removeScriptMessageHandler(forName:)` for every name added. Fires alongside the broader v1.4 `webkit.scriptmessage-handler-strong` pattern when the full bridge shape is present. | ### v1.7 catalog (1). SwiftData + Actor | Pattern ID | When it matches | Fix hint (summary) | |---|---|---| | `swiftdata.modelcontext-actor-cycle` | `ModelContext` + `DefaultSerialModelExecutor` (or `ModelExecutor`) + `Actor` in chain | Apple-documented quirk on iOS 17 (FB13844786, fixed in iOS 18 beta 1). Prefer the `@ModelActor` macro over hand-rolled executors; or hold `ModelContext` weakly inside a custom executor and re-resolve per operation. | ### v1.9 catalog (2). DebugSwift borrow | Pattern ID | When it matches | Fix hint (summary) | |---|---|---| | `uikit.viewcontroller-retained-after-pop` | A `*ViewController` subclass is in the heap with no `_parentViewController` / `_presentingViewController` edge in the chain | VC was popped but a closure, Combine sink, NotificationCenter block, or KVO observation is still retaining it. Audit closures captured in `viewDidLoad`, `Task { }` blocks that outlive the screen, KVO observations that never `invalidate()`, and delegate properties declared without `weak`. | | `swiftui.observable-write-on-every-render` | `ObservationRegistrar` / `ObservableObject` + SwiftUI view-graph class (`ViewGraph`, `_GraphValue`, `_ViewList`, `ViewBodyAccessor`, `DynamicViewProperty`) + `Closure context` all in chain | An `@Observable` is mutated while `body` is evaluating, scheduling another render. Move the side effect out of `body`. Use a computed property for derived values, or attach to `.onChange(of:)` / `.task(id:)` / `.onAppear`. | **Confidence tiers**: each pattern returns `high`, `medium`, or `low` based on how many specific signals match. If multiple patterns fire on the same cycle, all matches are returned. The highest-confidence one is `primaryMatch`, the rest are in `allMatches`. **Static analysis bridge (v1.6+)**: every classified cycle now carries a `staticAnalysisHint` field with three sub-fields: - `rule`: The SwiftLint rule that would have caught this at parse time (`weak_self`, `weak_delegate`, etc.), or `null` when no rule exists - `url`: Link to the rule docs OR to the open issue tracking the gap (e.g. SwiftLint #776 for `@escaping` retain cycles) - `explanation`: Plain-English description of the static-vs-runtime relationship Reinforces the differentiator: **memorydetective sees the runtime evidence linters miss**. Examples: - `combine.sink-store-self-capture` → `weak_self` (SwiftLint catches the closure form) - `concurrency.async-sequence-on-self` → `null` rule, with note that `[weak self]` does NOT help here - `delegate.strong-reference` → `weak_delegate` (SwiftLint catches it directly) **Fix template (v1.7+)**: every classified cycle also carries a `fixTemplate` with concrete Swift before/after code snippets. The agent reads the template, then adapts type/method names to the user's codebase via `swiftGetSymbolDefinition` / `swiftSearchPattern`. Example output for `combine.sink-store-self-capture`: ```jsonc { "fixTemplate": { "before": "pub.sink { v in self.value = v }.store(in: &bag) // ⚠️ retains self", "after": "pub.sink { [weak self] v in self?.value = v }.store(in: &bag)\n// OR for property-path: pub.assign(to: &$value)" } } ``` Where `staticAnalysisHint` says **which** linter rule complements this, `fixTemplate` shows **what** the fix actually looks like in code. **The textual `fixHint` remains deliberately prose, not code.** It explains the *why*; `fixTemplate` shows the *what*. The agent uses both. --- ## 3. How fixes actually flow from diagnosis to edit `memorydetective` covers the diagnose side **and the source-bridging side**. It tells you **what** is wrong, **where in the cycle**, **what type of fix** is needed, **where the relevant types live in your project** (via Swift LSP integration), and **every callsite that references them**. It does not edit your code. That final step still belongs to your LLM agent. So the workflow has two halves: | Half | Owned by `memorydetective` | Owned by the LLM agent | |---|---|---| | **Diagnose** | ✅ memgraph parsing, cycle classification, fix-hint catalog, hangs / allocations / app-launch / animation hitches | | | **Locate in source** | ✅ `swiftGetSymbolDefinition`, `swiftFindSymbolReferences`, `swiftSearchPattern`, `swiftGetSymbolsOverview`, `swiftGetHoverInfo` (SourceKit-LSP under the hood) | | | **Decide the actual edit** | | ✅ The agent reads the surrounding code, picks the right capture-list pattern, writes the diff | | **Apply the edit** | | ✅ The agent's `Edit`/`MultiEdit` tools write to the user's file | The split between "locate" (us) and "edit" (the agent) is intentional. Locating is a deterministic SourceKit-LSP query; editing requires understanding the user's surrounding code style, naming conventions, and intent. That's the LLM's strength. We give the agent every piece of structured information it needs to make the edit, and step back at the file-write boundary. ### Concrete end-to-end example Configuration (one-time): `memorydetective` registered as an MCP server in Claude Code, as shown in [section 1c](#1c-mcp-mode--the-actual-product-ux). You open Claude Code in your iOS project and ask: > **You:** Tem um leak. Memgraph em `~/Desktop/example-leaks.memgraph`. The cycle points at `DetailViewModel`. Find where it lives in the project `~/Development/myapp/` and suggest a fix. What Claude does, step by step: 1. **Calls `analyzeMemgraph(path)`** - Receives: 60,436 leaks, 4 ROOT CYCLE blocks, top cycle is a `_DictionaryStorage` chain. App-level classes in chain: `DetailViewModel`, `GraphQLClient`, `ItemRepositoryImpl`. 2. **Calls `classifyCycle(path)`** - Receives: `swiftui.tag-index-projection` (high confidence) + fix hint pointing at `.tag()` modifier capturing `self`. 3. **Calls `reachableFromCycle({ rootClassName: "DetailViewModel" })`** - Confirms 4 `DetailViewModel` instances and ~1100 `NSURLSessionConfiguration`s reachable from each. The VM is the culprit pinning the network stack as collateral, not the other way around. 4. **Calls `swiftSearchPattern({ pattern: "\\.tag\\(", filePath: "..." })` across views** - Surfaces 1 match in `MyApp/Views/MediaCarousel.swift:142`. 5. **Calls `swiftGetSymbolDefinition({ symbolName: "DetailViewModel", projectRoot: "~/Development/myapp/", candidatePaths: ["MyApp/ViewModels/"] })`** - Returns `MyApp/ViewModels/DetailViewModel.swift:18`. `class DetailViewModel: ObservableObject`. 6. **Calls `swiftFindSymbolReferences({ symbolName: "DetailViewModel", filePath: "..." })`** - Lists 7 callsites across the codebase. Helps the agent gauge fix blast radius. 7. **Presents the proposed fix in chat:** > **Claude:** Found the cycle. `MediaCarousel.swift:142` passes `onImageSliderTap` as a closure that captures `self`. The `.tag(index)` modifier on the carousel `ForEach` triggers the SwiftUI internal `TagIndexProjection` observation that pins `DetailViewModel`. > > Suggested fix: > ```swift > // before > onImageSliderTap: { index in > self.viewModel.handlePhotoTap(at: index) > } > > // after. Static helper + weak captures > onImageSliderTap: { [weak vm = _viewModel.wrappedValue, > weak coord = self.coordinator] index in > Self.handlePhotoTap(index: index, viewModel: vm, coordinator: coord) > } > > private static func handlePhotoTap( > index: Int, > viewModel: DetailViewModel?, > coordinator: DetailsCoordinator? > ) { /* ... */ } > ``` > > Want me to apply this? 8. **You accept** → Claude calls its own `Edit` tool → diff appears in your IDE → you review and commit. 9. **(Optional) Verify the fix:** export a fresh `.memgraph` after running the same flow, then ask Claude: > **You:** Did the fix work? Compare `~/Desktop/before.memgraph` to `~/Desktop/after.memgraph`. Claude calls `diffMemgraphs`. Instance counts dropped, the `swiftui.tag-index-projection` cycle is gone from `cycles.persisted`, present in `cycles.goneFromBefore`. ### End-to-end example: MetricKit production post-mortem (v1.18) Different lane, same shape. You collected `.mxdiagnostic` payloads from a TestFlight session, dropped them into `~/Downloads/metrickit-may/`, and you want to know what's actually happening to real users. > **You:** Tenho uma pasta `~/Downloads/metrickit-may/` com payloads MetricKit dos últimos 30 dias do TestFlight. O que está pegando os usuários? What Claude does: 1. **Calls `analyzeMetricKitPayload({ payloadDir: "~/Downloads/metrickit-may/" })`** - Returns `payloadCount: 47`, `timeRange: { start: "2026-04-15…", end: "2026-05-15…" }`, `crashCluster[]` with 5 entries, `hangHotspots[]` with 12 entries, `cpuExceptions[]` with 3, `diskWriteExceptions[]` empty. 2. **Reads the top crash cluster:** ```jsonc { "clusterKey": "exc=1|sig=11", "exceptionType": 1, "signal": 11, "topFrame": "MyApp 0x1a8c40", "occurrences": 18, "affectedBuilds": ["456", "457"], "sample": { "binaryUUID": "AAAA1111-...", "offsetIntoBinaryTextSegment": 1740864 } } ``` 18 crashes on `EXC_BAD_ACCESS` (SIGSEGV), all in builds 456 and 457. The `topFrame` is unsymbolicated (memorydetective ships raw `binaryUUID + offset` in v1; dSYM symbolication is a future tool). 3. **Reads `suggestedNextCalls`:** the analyzer matched `objc_release` in the top frame's deeper stack and emitted a `findCycles` chain hint. Claude surfaces it. 4. **Presents the synthesis:** > **Claude:** 47 payloads em 30 dias. Top issue: **18 crashes EXC_BAD_ACCESS** clusterizados num mesmo top frame, todos nos builds 456 e 457 (não está em 458 nem antes do 456). Padrão de retain-cycle release → recomendo capturar memgraph de uma sessão local rodando o mesmo flow + chamar `findCycles` pra identificar o cycle que tá causando o free-after-use. > > Segundo issue: **1 hang de 5.4s** com top frame em `libsqlite3.dylib` (3 amostras). Pattern db-lock, sugere `analyzeHangs` com `includeStackClassification` num `.trace` se você reproduzir o cenário. 5. **You ship a fix and verify** with the next batch of TestFlight payloads (re-run step 1 a week depois, check se `affectedBuilds` removeu o build com o fix). The pattern is identical to the memgraph example: agent orchestrates discovery + diagnosis, you make the editing decision. Difference is the input lane: synthetic memgraph captured locally vs. production payloads from real users. ### Why this is better than "memorydetective generates the diff" If `memorydetective` tried to generate a code patch, it would have to: - Parse Swift source - Understand the file's import graph - Track the actual variable names and types in scope - Match surrounding code style That's exactly what an LLM agent already does. And does well. Splitting the responsibility keeps each side simple. `memorydetective` knows **iOS perf**; the agent knows **your codebase**. They compose. --- ## 4. Common follow-up requests Once you have the diagnosis, here are useful follow-up prompts you can paste into Claude: | Prompt | What Claude calls | |---|---| | "I want to investigate a memgraph leak. What's the canonical sequence?" | `getInvestigationPlaybook({ kind: "memgraph-leak" })`. Returns the 6-step pipeline with `argsTemplate` for each tool. | | "How many `DetailViewModel` instances are leaking?" | `countAlive(path, className: "DetailViewModel")` | | "How many `NSURLSessionConfiguration`s are *inside* the cycle rooted at `DetailViewModel`?" | `reachableFromCycle(path, rootClassName: "DetailViewModel", className: "NSURLSessionConfiguration")` | | "Show the retain chain that keeps `DetailViewModel` alive." | `findRetainers(path, className: "DetailViewModel")` | | "Compare `~/Desktop/before.memgraph` to `~/Desktop/after.memgraph`. Did the leak go away?" | `diffMemgraphs(before, after)` | | "Did my fix actually resolve the `swiftui.tag-index-projection` cycle?" | `verifyFix(before, after, expectedPatternId: "swiftui.tag-index-projection")`. Returns PASS/PARTIAL/FAIL | | "Whitelist `MySingletonCache` (exact match) so it does not vote FAIL on the verify." | `verifyFix(before, after, expectedAliveClasses: [{ pattern: "MySingletonCache", mode: "exact" }])`. v1.17 introduces per-entry modes (`"exact" \| "substring" \| "regex"`); bare strings still default to substring for backwards compat. | | "Render the cycle as a Mermaid graph for the PR description." | `renderCycleGraph(path, format: "mermaid")` | | "Profile this app on my iPhone for 90 seconds and tell me about hangs." | `listTraceDevices` → `recordTimeProfile` → `analyzeHangs` | | "Pull the last 5 minutes of `error`-level logs from `MyApp`." | `logShow(last: "5m", process: "MyApp", level: "default")` | | "Run my XCUITest with leak detection." | `detectLeaksInXCUITest(workspace, scheme, testIdentifier, …)` | | **Production diagnostics (v1.18). For TestFlight / App Store payloads:** | | | "I have a `.mxdiagnostic` file from a user crash. What happened?" | `analyzeMetricKitPayload({ payloadPath: "~/Downloads/payload.mxdiagnostic" })`. Returns `crashCluster[]` ranked by occurrences, with top frame + affected builds. | | "I have a folder of `.mxdiagnostic` files from the last 30 days. Aggregate them." | `analyzeMetricKitPayload({ payloadDir: "~/MetricKit/", groupBy: "exception-type" })`. Walks every `.mxdiagnostic` in the dir, clusters crashes across all of them, surfaces the top regression. | | "Group crashes by which framework they originate in (not by exception type)." | `analyzeMetricKitPayload({ payloadDir: "...", groupBy: "binary" })`. Useful when one third-party SDK is misbehaving across multiple call sites. | | "What was the longest hang real users hit this week?" | `analyzeMetricKitPayload({ payloadDir: "..." })` then read `hangHotspots[0].hangDurationMs`. Pre-converted to ms from Apple's localized strings (`"5.4 sec"`, `"20秒"`). | | **Verify-fix orchestration (v1.8). For the macOS 26.x `leaks` regression and deterministic before/after snapshots:** | | | "Build, boot, and launch `MyApp` ready for leak investigation." | `bootAndLaunchForLeakInvestigation({ workspace, scheme, simulator: { name: "iPhone 15" } })`. Returns host PID + UDID + bundle id with `MallocStackLogging=1` already applied. | | "Reproduce the carousel leak: tap Explore, swipe, then back, repeat 5 times." | `replayScenario({ simulatorUDID, actions: [{ type: "tap", label: "Explore" }, { type: "swipe", from: [350, 400], to: [50, 400] }, { type: "tap", label: "Back" }], repeat: 5 })` | | "Snapshot before / after the fix into `~/Desktop/snaps/`." | `captureScenarioState({ simulatorUDID, pid, outputDir: "~/Desktop/snaps/", label: "before" })` then ship the fix and call again with `label: "after"`. Then `diffMemgraphs(before.memgraph, after.memgraph)`. | | **Source bridging. Combine with the memory tools above:** | | | "Where is `DetailViewModel` declared in this project?" | `swiftGetSymbolDefinition(symbolName, candidatePaths)` | | "Find every reference to `DetailViewModel` across the codebase." | `swiftFindSymbolReferences(symbolName, filePath)` | | "What types live in `MediaCarousel.swift`?" | `swiftGetSymbolsOverview(filePath)` | | "What's the type at this position in this file?" | `swiftGetHoverInfo(filePath, line, character)` | | "Search for `[weak self]` captures in this file." | `swiftSearchPattern(filePath, pattern: "\\[weak self\\]")` | The agent decides which tool to call based on your prompt. You don't need to remember the tool names. --- ## 5. Troubleshooting ### `memorydetective: command not found` The npm global install isn't on your `$PATH`. Check: ```bash which memorydetective npm prefix -g ``` If `npm prefix -g` returns something not in your `$PATH`, add it. Or use the binary directly: ```bash $(npm prefix -g)/bin/memorydetective --version ``` ### `analyzeTimeProfile` returns a SIGSEGV notice Known limit. `xcrun xctrace export` of the `time-profile` schema crashes on heavy unsymbolicated traces. Workarounds (in order of effort): 1. Open the trace once in Instruments.app (forces symbolication), then close it. Re-run `analyzeTimeProfile`. 2. Re-record with a shorter `--time-limit` (try 30 s instead of 90 s). 3. For hang analysis specifically, use `analyzeHangs` instead. It parses a different (lighter) schema that doesn't crash. ### `captureMemgraph` fails on a physical iOS device By design. `leaks(1)` only attaches to processes on the local Mac (which includes iOS simulators). Memory Graph capture from a physical device goes through Xcode's debugger over USB. Different mechanism, no public CLI equivalent. Use Xcode's Memory Graph button + File → Export Memory Graph for physical devices. ### `captureMemgraph` returns `workaroundNotice: { issue: "macos-26-task-for-pid-broken" }` or `"minimal-corpse"` There are two related-but-distinct failure modes here. v1.9 routes them apart so the agent (and you) can pick the right recovery without trial and error. **`minimal-corpse`** fires when the target process was not launched with `MallocStackLogging=1`. Relaunching with the env var typically resolves it (see `bootAndLaunchForLeakInvestigation`). **`macos-26-task-for-pid-broken`** fires on macOS 26.x (Darwin kernel 25.x) when `leaks` fails with the same DYLD-info signature, but where the root cause is Apple's `task_for_pid` kernel regression. `MallocStackLogging` does not resolve it because the regression sits below the libmalloc layer. Other CLI memory-introspection tools (`heap`, `xctrace --template Allocations`) hit the same wall on the same host. The reliable workaround is **target an iOS 18 simulator runtime instead** (install via Xcode > Settings > Platforms > +iOS 18.x). This was empirically validated during the notelet investigation 2026-05-12 where every CLI path failed on iPhone 17 / iOS 26.4 sim but worked first-try on iPhone 16 / iOS 18 sim with the same demo app and capture flow. memorydetective surfaces both issue ids in the structured shape: ```jsonc { "ok": false, "pid": 49581, "workaroundNotice": { "issue": "minimal-corpse", "message": "leaks --outputGraph could not introspect the target process...", "fallbacks": [ "Relaunch the app with MallocStackLogging=1 (use bootAndLaunchForLeakInvestigation).", "Open Xcode > Debug > View Memory Graph Hierarchy + File > Export Memory Graph.", "Record an Allocations trace via recordTimeProfile + analyzeAllocations." ] }, "suggestedNextCalls": [ { "tool": "recordTimeProfile", "args": { "template": "Allocations", ... } }, { "tool": "analyzeAllocations", "args": { ... } } ] } ``` Recovery options: **For `minimal-corpse` on non-macOS-26.x hosts:** 1. **Use `bootAndLaunchForLeakInvestigation`**. Single call that builds, boots, installs, and launches with the right env vars in one shot. Returns the host PID ready for `captureMemgraph`. This is the canonical fix. 2. **Xcode manual export.** With the app attached to Xcode debugger: Debug > View Memory Graph Hierarchy, then File > Export Memory Graph. Pass the resulting `.memgraph` to `analyzeMemgraph`. 3. **Allocations fallback.** Follow `suggestedNextCalls` to record an `Allocations` trace and inspect with `analyzeAllocations`. Not full cycle detection but reveals top live classes. **For `macos-26-task-for-pid-broken` on macOS 26.x:** 1. **iOS 18 simulator runtime (canonical).** Install via Xcode > Settings > Platforms > +iOS 18.x. Build + launch your app on the iOS 18 sim instead of the macOS 26.x sim. `leaks`, `captureMemgraph`, `captureScenarioState`, and all downstream tools work normally there. This is the only fully-automatable path today. 2. **Xcode manual export with the scheme toggle.** Edit your Run scheme: Diagnostics tab > check Malloc Stack Logging. Then `Cmd+R`, drive the scenario, pause the debugger, Debug > View Memory Graph Hierarchy, File > Export Memory Graph. Without the scheme toggle, View Memory Graph fails with `VMUTaskMemoryScanner` errors on macOS 26.x. 3. **`recordTimeProfile` Allocations trace.** Run the Allocations template against the live process, then `analyzeAllocations` for class counts. Avoid `--time-limit` past 30s on macOS 26.x sims due to a separate xctrace bug (see `recordTimeProfile` notes). Set `MEMORYDETECTIVE_SUPPRESS_PLATFORM_ADVISORY=1` to silence the proactive stderr banner once you have settled on a workaround. The `getInvestigationPlaybook({ kind: "memgraph-leak" })` response carries a `troubleshooting` field with these recovery paths inline so an LLM agent can branch deterministically. ### `recordTimeProfile` times out / produces a 52K bundle that fails to export `xcrun xctrace record --time-limit Ns` is broken on macOS 26.x for simulator targets. The recording wedges past the requested time limit, exits when SIGKILL fires, and produces a `.trace` bundle that contains only `Trace1.run/RunIssues.storedata` (no actual schema data). `xctrace export --toc` against the resulting bundle returns `Document Missing Template Error`. The bug is upstream (Apple) and survives Xcode 26.5 (build 17F42, xctrace 16.0) per the 2026-05-15 re-validation. The symptom in memorydetective: ```jsonc { "ok": false, "recordingTimedOut": true, "tracePath": "/tmp/x.trace", "workaroundNotice": { "issue": "macos-26-xctrace-record-broken", "...": "..." } } ``` It hits every `xctrace`-based tool the same way (this MCP, XcodeTraceMCP, raw `xcrun xctrace record` calls in a shell, third-party scripts wrapping the CLI). Not a memorydetective bug. Recovery options, ranked by automation cost: 1. **`recordViaInstrumentsApp` MCP tool (v1.16+).** Opens Instruments.app for you, surfaces step-by-step instructions in the response, then watches a directory for the saved `.trace`. Once it appears, the tool returns the path plus a chained `inspectTrace` summary. The user-in-loop step is the recording itself (pick template, hit Record, hit Stop, hit Save). Why is it user-in-loop? Instruments.app's AppleScript surface is minimal: queries on the `document` class only, no verbs for start/stop/select-template (see `Xcode.app/Contents/Applications/Instruments.app/Contents/Resources/Instruments.sdef`). The watcher polls every 5 seconds and considers a bundle "saved" after its mtime is stable for 10 seconds. **v1.17 fixed the common "I hit Save and accepted the Desktop default" miss**: the watcher now also queries running Instruments.app via AppleScript each poll for any document whose file path was set after the tool started. On match, returns the path with `savedOutsideWatchDir: true` so you do not have to re-record into `watchDir`. 2. **Record on an older macOS host with Xcode 26.0 if you have one available.** Pre-regression `xctrace record` produces clean traces. The 2026-05-15 validation used `~/Desktop/wishlist-tti-device.trace`, a Time Profiler trace captured this way: 91s recording, 35 hangs detected, 44 418 time-profile samples, fully analyzable by memorydetective's trace-side tools. 3. **Record manually via Instruments.app GUI without the wrapper.** Instruments.app on macOS 26.x still produces valid `.trace` bundles. Open Instruments, pick a template, choose the simulator + app, hit Record, drive the scenario, Stop, Save. Then point `inspectTrace` / `summarizeTrace` / `analyzeHangs` / `analyzeTimeProfile` / `compareTracesByPattern` at the saved bundle. All trace-side analyzers work normally on Instruments-recorded `.trace` bundles. 4. **Record against a physical device (USB or wireless).** The regression appears to be simulator-specific. `xctrace record --device --launch ` against a real iPhone / iPad does NOT exhibit the same wedge. Use this when you have a physical target available. 5. **Wait for Apple.** The Feedback assistant is the right escalation path. The regression has shipped through Xcode 26.4 + 26.5; expect a fix in 26.6 or later. memorydetective's `recordViaInstrumentsApp` (v1.16, hardened in v1.17) + pre-flight probe (v1.14) cover the automated paths around the regression until Apple ships a fix. v1.17 also added a `bundleStatus: "unknown" \| "salvageable" \| "wedged"` field on `recordTimeProfile` responses and a fault-tolerant fallback on `inspectTrace` so the entire MCP call no longer throws when xctrace refuses a wedged bundle. ### `replayScenario` returns `workaroundNotice: { issue: "axe-not-found" }` The `axe` CLI is not on your `$PATH`. Install with: ```bash brew install cameroncooke/axe/axe ``` `axe` is a soft dependency. Only `replayScenario` and the `uiTree` sub-capture of `captureScenarioState` need it. Other tools work without it. ### Tests pass locally but fail in CI The stress test has a wallclock budget that's tighter on slower runners. If you see `expected NNNms to be less than 2000`, bump `PARSE_BUDGET_MS` in `src/stress.test.ts`. ### `detectLeaksInXCUITest` says "after-capture failed" The app process exited before `leaks --outputGraph` could attach. Configure your XCUITest to keep the app alive at end-of-test (e.g. `XCTAssertTrue(true); _ = XCTWaiter.wait(for: [...], timeout: 1.0)`), or use a longer simulator boot. This tool is **experimental** in v1.0. Feedback welcome. --- ## 6. Pipeline awareness (suggestedNextCalls + playbooks) Discovery is data, not inference. As of v1.3, the tools that matter most return a `suggestedNextCalls` field with pre-populated arguments and a one-sentence rationale per entry. The orchestrating agent can chain calls without re-reasoning over the result. ### `suggestedNextCalls`: example from `classifyCycle` ```jsonc { "ok": true, "totalCycles": 4, "classified": [ /* ... */ ], "suggestedNextCalls": [ { "tool": "swiftSearchPattern", "args": { "pattern": "\\.tag\\(", "filePath": "" }, "why": "Locate the code construct implicated by swiftui.tag-index-projection. The regex matches the SwiftUI signal that produces this cycle." }, { "tool": "swiftGetSymbolDefinition", "args": { "symbolName": "DetailViewModel", "candidatePaths": [""] }, "why": "Jump to the declaration of DetailViewModel, the user-defined type captured in this cycle." } ] } ``` The agent reads `suggestedNextCalls`, fills in the `<...>` placeholders from project context, and chains. No re-reasoning required. ### `getInvestigationPlaybook`: start here for a fresh investigation For agents that haven't seen the project before, ask for the canonical pipeline first: ```jsonc { "tool": "getInvestigationPlaybook", "args": { "kind": "memgraph-leak" } } ``` Returns a 6-step sequence with `argsTemplate` per step: ```jsonc { "kind": "memgraph-leak", "summary": "Diagnose a SwiftUI / Combine retain cycle from a `.memgraph` snapshot, locate the offending code, and propose a fix.", "steps": [ { "step": 1, "tool": "analyzeMemgraph", "purpose": "..." }, { "step": 2, "tool": "classifyCycle", "purpose": "..." }, { "step": 3, "tool": "reachableFromCycle", "purpose": "..." }, { "step": 4, "tool": "swiftSearchPattern", "purpose": "..." }, { "step": 5, "tool": "swiftGetSymbolDefinition", "purpose": "..." }, { "step": 6, "tool": "swiftFindSymbolReferences", "purpose": "..." } ] } ``` Five playbooks ship in v1.3: | Kind | Use when | |---|---| | `memgraph-leak` | You have a `.memgraph` and want to find + fix a retain cycle | | `perf-hangs` | App feels slow; suspect main-thread blocking | | `ui-jank` | Animations drop frames | | `app-launch-slow` | Cold-start time is over budget | | `verify-fix` | Confirm a fix actually resolved the cycle | ### Tool description tags Every tool description starts with a category tag so related tools are visible as a group: | Tag | What | |---|---| | `[mg.memory]` | memgraph parsing, cycle classification, retainer chains | | `[mg.trace]` | xctrace schemas (hangs, allocations, app-launch, animation hitches, time-profile) | | `[mg.code]` | Swift source bridging via SourceKit-LSP | | `[mg.log]` | macOS unified logging (`log show` / `log stream`) | | `[mg.discover]` | xctrace device + template listing | | `[mg.render]` | Cycle visualization (Mermaid + Graphviz) | | `[mg.ci]` | XCUITest leak detection | | `[meta]` | Pipeline-discovery tools like `getInvestigationPlaybook` | The tag is leading text in the MCP description, so it shows up in any tools/list output and inside Claude Code's "deferred tools" list. ## 7. MCP Resources + Prompts (catalog browsing + slash commands) Since v1.6, memorydetective surfaces two MCP-spec features beyond raw Tools. ### Resources: browsable cycle-pattern catalog Each of the 33 catalog patterns is exposed as a read-only MCP resource at `memorydetective://patterns/{patternId}`. The body is markdown. The pattern name, the fix hint, and a footer pointing at how it composes with `classifyCycle`'s `primaryMatch`. ```jsonc // resources/list response (excerpt) { "resources": [ { "uri": "memorydetective://patterns/swiftui.tag-index-projection", "name": "SwiftUI .tag(...) closure-over-self cycle", "description": "SwiftUI .tag(...) closure-over-self cycle", "mimeType": "text/markdown" }, { "uri": "memorydetective://patterns/concurrency.async-sequence-on-self", "name": "`for await` over an infinite AsyncSequence pins self via the consuming Task", "description": "`for await` over an infinite AsyncSequence pins self via the consuming Task", "mimeType": "text/markdown" }, … ] } ``` **Why this matters:** an agent that needs to ask "do you cover X?" can browse the resource list cheaply (no tool call). A UI-aware client can render the catalog as a sidebar or completion source. ### Prompts: investigation playbooks as slash commands Five prompts ship, one per investigation kind: | Prompt name | Surfaces in Claude Code as | Args | Equivalent tool sequence | |---|---|---|---| | `investigate-leak` | `/investigate-leak` | `memgraphPath` | `analyzeMemgraph` → `classifyCycle` → `reachableFromCycle` → `swiftSearchPattern` → `swiftGetSymbolDefinition` → `swiftFindSymbolReferences` | | `investigate-hangs` | `/investigate-hangs` | `tracePath` | `listTraceDevices` → `recordTimeProfile` (if needed) → `analyzeHangs` → `swiftSearchPattern` | | `investigate-jank` | `/investigate-jank` | `tracePath` | `recordTimeProfile` (Animation Hitches template) → `analyzeAnimationHitches` → `swiftFindSymbolReferences` | | `investigate-launch` | `/investigate-launch` | `tracePath` | `recordTimeProfile` (App Launch template) → `analyzeAppLaunch` → `swiftSearchPattern` | | `verify-cycle-fix` | `/verify-cycle-fix` | `before`, `after` | `diffMemgraphs` → `classifyCycle` | When the user invokes a prompt, the server fills the canonical playbook's argument templates with the user-provided values and returns a ready-to-execute brief. The agent then executes the steps. Same tool calls as if the user had typed them out, just orchestrated. > **Both surfaces (Tools + Resources + Prompts) are independent. Clients that only support Tools still get the full catalog via `classifyCycle`.** Resources and Prompts are pure-add UX improvements for clients that surface them. ## 8. Where to go from here - **Add a new cycle pattern**: see the *Adding a cycle pattern to `classifyCycle`* section in [`README.md`](./README.md#contributing). - **Run a custom analysis from scratch**: every tool's input schema is documented via the MCP `tools/list` request. Hit the server with `{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}` over stdio. - **Open an issue**: https://github.com/carloshpdoc/memorydetective/issues. Bug reports, feature requests, and pattern contributions are all welcome.