Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
d449b11
docs: design spec for call-stack AST-detach heap reduction
n1ckl0sk0rtge Jul 5, 2026
675e5c3
docs: use synthetic detached SyntaxToken instead of value-model change
n1ckl0sk0rtge Jul 5, 2026
c3ca731
docs: task-by-task implementation plan for call-stack AST-detach
n1ckl0sk0rtge Jul 5, 2026
1224843
test: first true cross-file detection guard (compiled-classpath Check…
n1ckl0sk0rtge Jul 5, 2026
66b7e5e
test: harden cross-file guard with field-constant (detachable) + arra…
n1ckl0sk0rtge Jul 5, 2026
e12849e
feat(engine): AST-free DetachedSyntaxToken for detached value locations
n1ckl0sk0rtge Jul 5, 2026
fa51c96
feat(engine): AST-free DetachedScanContext
n1ckl0sk0rtge Jul 5, 2026
4654a6e
feat(engine): ArgSnapshot + MethodMatcher.matchKeys (tree-free match)
n1ckl0sk0rtge Jul 5, 2026
afe61bc
feat(engine): isDetachableCall predicate (method invocation without N…
n1ckl0sk0rtge Jul 5, 2026
b1fd55a
style: spotless formatting on cross-file test
n1ckl0sk0rtge Jul 5, 2026
073b802
refactor(engine): CallContext -> sealed interface with RetainedCall v…
n1ckl0sk0rtge Jul 6, 2026
c86fcc8
feat(engine): DetachedCall variant (tree-free recorded call)
n1ckl0sk0rtge Jul 6, 2026
9efdfa5
feat(engine): detach recorded calls at leaveFile with cross-file repl…
n1ckl0sk0rtge Jul 6, 2026
7e6253b
perf(engine): key-indexed subscription lookup in onNewHookSubscription
n1ckl0sk0rtge Jul 6, 2026
6fed90c
docs: rewrite plan as as-built implementation record
n1ckl0sk0rtge Jul 6, 2026
05bdda9
refactor(engine): reorder imports, suppress field reflection warning
n1ckl0sk0rtge Jul 6, 2026
21443aa
docs: design for self-contained call-stack heap/perf harness
n1ckl0sk0rtge Jul 6, 2026
f1f3264
docs: task-by-task plan for call-stack heap/perf harness
n1ckl0sk0rtge Jul 6, 2026
77bff0a
feat(engine): CallContextStats accessor for call-stack retention
n1ckl0sk0rtge Jul 6, 2026
d2aa0fc
feat(engine): expose callContextStats through Handler/ILanguageSupport
n1ckl0sk0rtge Jul 6, 2026
7cc54ba
test(java): synthetic cross-file crypto corpus generator
n1ckl0sk0rtge Jul 6, 2026
cca8a5c
build: exclude @Tag(performance) tests from default surefire run
n1ckl0sk0rtge Jul 6, 2026
7f03c62
test(java): manual call-stack heap/perf harness (@Tag performance)
n1ckl0sk0rtge Jul 6, 2026
5a5df34
docs: performance & heap testing guide (self-contained harness + Keyc…
n1ckl0sk0rtge Jul 6, 2026
1c6c49a
docs: design for call-stack heap attribution & trim (H1+H2)
n1ckl0sk0rtge Jul 6, 2026
80c58c0
docs: task-by-task plan for call-stack heap attribution (H1)
n1ckl0sk0rtge Jul 6, 2026
10f5f79
docs: correct H2 eligibility-predicate premise in heap-trim spec
n1ckl0sk0rtge Jul 6, 2026
f96cb7b
feat(plugin): heap-attribution summary record for scan-floor analysis
n1ckl0sk0rtge Jul 6, 2026
7123bb2
feat(plugin): log heap-attribution populations at end of scan
n1ckl0sk0rtge Jul 6, 2026
2732d73
test(java): report detectedNodes count in call-stack perf harness
n1ckl0sk0rtge Jul 6, 2026
38c01be
docs: post-detach floor attribution runbook (H1) + H2 decision rule
n1ckl0sk0rtge Jul 6, 2026
1d0e442
docs: record H1 floor-attribution results (Keycloak) + revised H2 rou…
n1ckl0sk0rtge Jul 6, 2026
280b7e9
refactor(java): reorder imports in `JavaDetachedIssueReporter` for co…
n1ckl0sk0rtge Jul 6, 2026
7c20aea
fix(engine): match detached call-stack keys with hook-context semantics
n1ckl0sk0rtge Jul 6, 2026
d39f087
test(python,go): cross-file guard tests for shared call-stack bucketing
n1ckl0sk0rtge Jul 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,8 @@ Run with `go run gen_package.go`, then delete the script.

If you encounter difficulties or unexpected results while installing the plugin with SonarQube, or when trying to scan a repository, please check out our guide [*Testing your configuration and troubleshooting*](docs/TROUBLESHOOTING.md) to run our plugin with step-by-step instructions.

To measure the plugin's runtime performance and heap usage — including a full end-to-end scan of a large project (Keycloak) — see [*Performance & Heap Testing*](docs/PERFORMANCE_TESTING.md).

## Contribution Guidelines

If you'd like to contribute to Sonar Cryptography Plugin, please take a look at our
Expand Down
391 changes: 391 additions & 0 deletions docs/PERFORMANCE_TESTING.md

Large diffs are not rendered by default.

138 changes: 138 additions & 0 deletions docs/superpowers/plans/2026-07-05-callstack-detach-implementation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
# Call-stack AST-Detach Heap Reduction — Implementation Record (as-built)

> **Status:** Implemented on branch `feat/callstack-ast-detach`. Full clean build green. One task
> (heap measurement) deferred. This document records what was actually built; it diverges
> substantially from the original step-by-step plan because several assumptions were corrected
> during execution (see "Divergences from the original plan").

**Goal:** Stop the per-language `CallStackAgent` from pinning whole-file ASTs for the entire scan
(the measured ~7 GB / ~179k-`CallContext` runtime leak) without regressing detection or SonarQube
issue reporting — same-file *or* cross-file.

**Outcome:** Recorded calls are detached from their AST when their file finishes analysis. A file's
AST becomes garbage-collectable after `leaveFile`, while cross-file hook matching continues from a
tree-free snapshot. Same-file detections keep full behavior; cross-file detections produce CBOM
nodes and raise SonarQube issues without pinning any AST.

**Tech stack:** Java 17, Maven multi-module, sonar-java 8.0.1, JUnit 5 + AssertJ + `CheckVerifier`.

---

## As-built architecture

A recorded call is a sealed `CallContext<R,T>`:
- `RetainedCall(T tree, IScanContext publisher, @Nullable DetachedCall detachedForm)` — holds the
live tree; used while the call's file is being analyzed.
- `DetachedCall(IType invokedObjectType, String methodName, List<IType> parameterTypes,
List<ArgSnapshot> arguments, DetachedScanContext publisher)` — tree-free.

**Lifecycle:**
1. **Record (file live):** `JavaDetectionEngine.recordCall` stores a `RetainedCall`. If the call is
detachable (`isDetachableCall`), it also pre-builds the `DetachedCall` *now* (arguments resolved
while the file's AST + symbols are live) and stashes it in `RetainedCall.detachedForm`.
2. **Same-file fire (file live):** hook detections during the file's own analysis match/replay
through the `RetainedCall`'s live tree + context, so their SonarQube issues and value resolution
are unchanged.
3. **`leaveFile`:** `JavaBaseDetectionRule.leaveFile` → `ILanguageSupport.notifyLeaveFile` →
`CallStackAgent.detachCallsForFile(inputFile)` swaps each of that file's `RetainedCall`s for its
`detachedForm`, dropping the tree → the file's AST is GC-eligible.
4. **Cross-file fire (file gone):** later hooks match the `DetachedCall` via
`MethodMatcher.matchKeys(...)` and replay from its `ArgSnapshot`s. The detection value's location
is an AST-free `DetachedSyntaxToken`; its SonarQube issue is raised via `SonarComponents` (see
Reporting).

**Detachability (`JavaLanguageSupport.isDetachableCall`):** a `MethodInvocationTree` whose arguments
contain no `NEW_ARRAY` (the one value-factory-dependent resolution case, `SizeFactory`). It does
**not** require `methodSymbol().declaration() != null` — cross-file callees resolve via the binary
classpath, so `declaration()` is null for exactly the cross-file calls we must detach.

**Reporting (cross-file, no AST):** sonar-java's public issue API is entirely tree-based, and any
tree pins the AST. So cross-file detached issues are raised through the internal
`SonarComponents.reportIssue(new AnalyzerMessage(check, inputFile, TextSpan, message, 0))` —
`SonarComponents` is shared per scan and pins no AST. It is captured at record time and read
reflectively from the protected `DefaultModuleScannerContext.sonarComponents` field
(`JavaDetachedIssueReporter`, with a graceful null fallback to CBOM-only). `IDetachedIssueReporter`
abstracts this in the engine; `DetachedScanContext.reportIssue` delegates to it using the
`DetachedSyntaxToken`'s captured range.

---

## Files (as-built)

Engine (`engine/src/main/java/com/ibm/engine/`):
- `callstack/CallContext.java` — sealed interface (`permits RetainedCall, DetachedCall`).
- `callstack/RetainedCall.java` — NEW — `(tree, publisher, @Nullable detachedForm)`.
- `callstack/DetachedCall.java` — NEW — tree-free record.
- `callstack/ArgSnapshot.java` — NEW — per-argument resolved values + location.
- `callstack/DetachedSyntaxToken.java` — NEW — AST-free `SyntaxToken` (value location).
- `callstack/DetachedScanContext.java` — NEW — AST-free `IScanContext` (InputFile + path + reporter).
- `callstack/IDetachedIssueReporter.java` — NEW — engine-side reporting abstraction.
- `callstack/CallStackAgent.java` — variant-aware add/match; `detachCallsForFile`; key-indexed
subscription lookup; `visitedTreeObjects` removed (per-bucket dedup).
- `detection/MethodMatcher.java` — `matchKeys(IType, name, List<IType>)` overload.
- `detection/DetectionStore.java`, `detection/DetectionStoreWithHook.java` — fire path carries
`CallContext`; detached replay branch (`replayDetachedParameterHook`).
- `hooks/*` — `notify`/`onHookInvocation` carry `CallContext`; `isInvocationOn(CallContext)` matches
`DetachedCall` via `matchKeys`.
- `language/ILanguageSupport.java` — `isDetachableCall`, `parameterIndexOf`, `notifyLeaveFile`
defaults.
- `language/java/JavaLanguageSupport.java` — Java impls of the above.
- `language/java/JavaDetectionEngine.java` — `recordCall` / `buildDetachedCall` / `captureLocation`.
- `language/java/JavaDetachedIssueReporter.java` — NEW — `SonarComponents` reporter.

Java plugin (`java/src/`):
- `main/.../rules/detection/JavaBaseDetectionRule.java` — `leaveFile` hook.
- `test/files/rules/detection/crossfile/*` + `test/.../crossfile/CrossFileHookDetachTest.java`,
`IsDetachableCallTest.java` — NEW — the first multi-file tests (compiled-classpath technique).

---

## Testing

**Cross-file test technique (new to the repo):** `CheckVerifier.onFiles(...)` alone does not give
sibling sources shared semantics, so cross-file callee types don't resolve and hooks never match
(why no multi-file test ever existed). Production resolves cross-file types via `sonar.java.binaries`
(the project is compiled first). Reproduced by compiling the fixtures to `.class` in-test and passing
the dir to `CheckVerifier.withClassPath(...)`. See `CrossFileHookDetachTest`.

**What the cross-file guard asserts:** all three detach branches resolve cross-file into the CBOM
(literal → detached, field-constant → detached, `new byte[]` → retained). The retained (array) case
also asserts its SonarQube issue via `verifyIssues`.

**`CheckVerifier` limitation (documented):** it reads issues only from the test context's
`getIssues()` (populated by `context.reportIssue`), not from `SonarComponents`. So cross-file
detached squid issues (retro-scan order) are production-correct but not `verifyIssues`-observable;
they are asserted via CBOM values instead.

---

## Divergences from the original plan (and why)

1. **Detach at `leaveFile`, not at record time.** Record-time detach routed same-file hook
detections through the tree-free path, so they lost their live-context SonarQube issues and broke
~7 `rules/resolve` tests. Deferring to `leaveFile` keeps same-file detections fully live.
2. **`isDetachableCall` dropped the `declaration() != null` check.** That check (from the abandoned
Phase-1 *filter* rationale) is null for every cross-file call (binary-classpath resolution), so it
would have made cross-file calls non-detachable — silently defeating the heap goal.
3. **Cross-file reporting via internal `SonarComponents` + reflection.** The spec assumed
`reportIssue` was off the path; it is not (`report()` yields issues). Public reporting is
tree-based (AST-pinning), so the internal `SonarComponents` path was required.
4. **No explicit `JavaTranslator` branch for `DetachedSyntaxToken`.** Unnecessary — it flows through
`getDetectionContextFrom`'s generic token path correctly.

---

## Remaining

- **Heap measurement (deferred):** scan a large compiled project (e.g. keycloak via `mvn
sonar:sonar`, or supply `sonar.java.libraries`) under a constrained `MAVEN_OPTS="-Xmx<low>"`;
capture `jmap -histo` before/after and record the drop in retained `CallContext`/AST and peak
heap, versus the ~179k / ~7 GB baseline in
`docs/superpowers/plans/2026-07-05-callstack-hooks-heap-reduction.md`. This is the piece that
quantifies the win and should run before merge.

## Follow-ups worth a reviewer's eye

- `JavaDetachedIssueReporter` reflectively reads an internal sonar-java field — fragile across major
upgrades (graceful null fallback mitigates). Consistent with the existing `ExpressionUtils` usage.
- Enum accesses, Python and Go remain on the always-retained path (unchanged) by design.
Loading