Skip to content

Latest commit

 

History

History
634 lines (470 loc) · 43 KB

File metadata and controls

634 lines (470 loc) · 43 KB

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. For the full changelog, see CHANGELOG.md.


1. Three ways to use it

1a. CLI mode. Quickest way to see it work

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<SwiftUI.AnyHashable2, SwiftUI…
    chain length: 545 nodes
    app-level classes in chain: Closure context, DetailViewModel,
        ItemRepositoryImpl, ItemGraphQLDataSource, GraphQLClient

  Diagnosis:
    60436 leaks; 4 ROOT CYCLE blocks. Largest top-level cycle:
    Swift._DictionaryStorage… (chain of 545 nodes). App-level
    classes in chains: Closure context, DetailViewModel, …

Then ask the classifier for fix advice:

memorydetective classify ~/Desktop/myapp.memgraph

You see one block per ROOT CYCLE, like:

  Root: Swift._DictionaryStorage<SwiftUI.AnyHashable2, SwiftUI…
    Match: swiftui.tag-index-projection (high confidence)
    Fix hint:
      Replace `[weak self]` capture in tap closures with a static
      helper, OR weak-capture the coordinator/view-model directly
      with `[weak coord = self.coordinator]`. The `.tag()` modifier
      on photo carousels is the usual culprit.
    Also matched: swiftui.dictstorage-weakbox-cycle,
                  closure.viewmodel-wrapped-strong,
                  swiftui.foreach-state-tap

1b. JSON mode. For scripts and CI

memorydetective analyze ~/Desktop/myapp.memgraph --json | jq .totals
memorydetective classify ~/Desktop/myapp.memgraph --json | jq '.classified[0].primaryMatch'

The JSON shape mirrors the MCP tool's response. Same fields, no ANSI colours, ready to pipe into anything.

1c. MCP mode. The actual product UX

This is what we built it for: an LLM agent (Claude Code, Claude Desktop, Cursor, Cline, Kiro, …) drives the investigation by chat.

Add to your MCP client config (Claude Code shown):

// ~/.claude/settings.json
{
  "mcpServers": {
    "memorydetective": { "command": "memorydetective" }
  }
}

Open Claude Code in your iOS project and just ask:

Diagnose ~/Desktop/myapp.memgraph and find where to fix in this codebase.

Claude orchestrates the full flow (see section 3).


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<Int> 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<AnyLocationBase>> 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-captureweak_self (SwiftLint catches the closure form)
  • concurrency.async-sequence-on-selfnull rule, with note that [weak self] does NOT help here
  • delegate.strong-referenceweak_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:

{
  "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.

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 NSURLSessionConfigurations 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:

    // 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:

    {
      "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 NSURLSessionConfigurations 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." listTraceDevicesrecordTimeProfileanalyzeHangs
"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:

which memorydetective
npm prefix -g

If npm prefix -g returns something not in your $PATH, add it. Or use the binary directly:

$(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:

{
  "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:

{
  "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 <UUID> --launch <app> 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:

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

{
  "ok": true,
  "totalCycles": 4,
  "classified": [ /* ... */ ],
  "suggestedNextCalls": [
    {
      "tool": "swiftSearchPattern",
      "args": {
        "pattern": "\\.tag\\(",
        "filePath": "<set to a candidate Swift file in your project>"
      },
      "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": ["<set to a Sources/ or app target directory>"]
      },
      "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:

{
  "tool": "getInvestigationPlaybook",
  "args": { "kind": "memgraph-leak" }
}

Returns a 6-step sequence with argsTemplate per step:

{
  "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.

// 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 analyzeMemgraphclassifyCyclereachableFromCycleswiftSearchPatternswiftGetSymbolDefinitionswiftFindSymbolReferences
investigate-hangs /investigate-hangs tracePath listTraceDevicesrecordTimeProfile (if needed) → analyzeHangsswiftSearchPattern
investigate-jank /investigate-jank tracePath recordTimeProfile (Animation Hitches template) → analyzeAnimationHitchesswiftFindSymbolReferences
investigate-launch /investigate-launch tracePath recordTimeProfile (App Launch template) → analyzeAppLaunchswiftSearchPattern
verify-cycle-fix /verify-cycle-fix before, after diffMemgraphsclassifyCycle

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