Skip to content

feat(drift): add --tree for structural version-to-version comparison - #76

Merged
copyleftdev merged 1 commit into
mainfrom
feat/tree-diff
Jul 30, 2026
Merged

feat(drift): add --tree for structural version-to-version comparison#76
copyleftdev merged 1 commit into
mainfrom
feat/tree-diff

Conversation

@copyleftdev

Copy link
Copy Markdown
Owner

Closes #62.

The gap this closes

Every signal in the npm dogfooding study read a package's presentation — declares a repository, has a description, looks mature. All 84 misses at the best operating point shared one profile: established-looking projects with real GitHub URLs, 15+ scripts, and test suites. A hijacked real package presents perfectly, so presentation-based detection is blind to it by construction.

What distinguishes those packages is not their state but their change: version N looked one way, version N+1 grew a payload. Nothing in vajra could express that.

vajra drift ./pkg-1.0.0 ./pkg-1.0.1 --tree \
  --input-format source --lang javascript --format json --quiet

Validated on real releases, with a control

Two adjacent versions of a real npm package flagged as malicious, against a normal patch release of a legitimate one:

legitimate patch (cli-table3 0.6.4→0.6.5) flagged release (@ejazullah/browser-mcp 0.0.57→0.0.58)
files changed 2 of 6 (33%) 59 of 61 (97%)
net node delta +153 −391,898

Three orders of magnitude apart. The control matters — "59 files changed" is meaningless without knowing what a normal patch looks like.

A finding I got backwards at first, worth recording. I assumed 0.0.58 had been obfuscated. Reading the files, the reverse is true: 0.0.57 carries _0x5082-style identifier mangling and 0.0.58 is clean Apache-licensed Microsoft code. The node count fell because obfuscation inflates AST nodes — consistent with the original study's finding. That doesn't weaken the result: a patch release that rewrites the structure of 97% of its files is not a normal patch whichever direction it runs, and knowing a package just switched its build pipeline is worth an alert either way. The docs now explain how to read the two columns together — a large delta concentrated in one or two files is the signature of an injected payload; a large delta spread across nearly every file is a build-pipeline change.

Design

  • Matched on path relative to each root. An npm tarball nests under package/, and two extraction directories have different names, so comparing absolute paths would report every file as both added and removed. Tested.
  • Compared by structural shape, not text. Reformatting and identifier renaming do not register; an added branch or a new call does. There's a test asserting that a fully renamed but structurally identical file reports unchanged — without that property every release would look like a rewrite and the signal would be worthless.
  • Unchanged files are counted but omitted from the per-file list, so output stays proportional to what actually moved.

Limits, documented rather than implied

  • Only files the format selector accepts are compared — .json by default, source under --input-format source. A payload dropped in a .sh or a binary is invisible here.
  • Parse failures are reported and counted as changed, never silently treated as unchanged. This is not hypothetical: package/lib/program.js in the real corpus exceeds the tree-sitter recursion limit on both sides, and lands in errors.
  • Shape equality is not semantic equality — a changed string literal is a value change, and this compares structure.

Tests

8 unit tests: identical trees, relative-path matching, added/removed/changed detection, value-only edits report unchanged, node-delta accounting for added and removed files, unparseable file reported and counted changed, non-directory input rejected on both sides, determinism.

9 CLI integration tests: identical releases, renaming is not a structural change, injected payload detected in an otherwise stable release with a positive delta isolated to one file, differently-named roots align, added/removed reported with specified ordering, JSON trees without --input-format, two directories required, text output states the comparison basis, byte-identical repeat runs.

Full workspace suite: 77 test binaries, 0 failures. cargo fmt --check clean. 0 clippy diagnostics in vajra-cli under --all-targets --all-features.

`drift` compares two documents. This answers a different question: what
structurally changed between two releases of the same artifact.

The distinction is the whole point. Every signal in the npm dogfooding study
read a package's *presentation* — declares a repository, has a description,
looks mature — and every one was blind to a compromised *established*
package, because a hijacked real package presents perfectly. All 84 misses
shared that profile. What distinguishes them is not state but change:
version N looked one way, version N+1 grew a payload.

  vajra drift ./pkg-1.0.0 ./pkg-1.0.1 --tree \
    --input-format source --lang javascript

Files are matched on path *relative to each root*, so the differently-named
extraction directories of two tarballs align instead of reporting every file
as both added and removed. Comparison is by structural shape, so
reformatting and identifier renaming do not register while an added branch
or a new call does. Reports added/removed/changed/unchanged counts, per-file
node deltas, and a net delta; unchanged files are omitted from the per-file
list but counted in the summary.

Validated on real releases. Two adjacent versions of an npm package flagged
as malicious, against a normal patch of a legitimate one:

                        legitimate patch    flagged release
  files changed         2 of 6 (33%)        59 of 61 (97%)
  net node delta        +153                -391,898

Three orders of magnitude apart. In that case the earlier version was the
obfuscated one and the later was clean, so the direction ran opposite to
what I first assumed — which is itself the point: a patch release that
rewrites the structure of 97% of its files is not a normal patch whichever
way it runs, and the docs say how to read the two columns together. A large
positive delta concentrated in one or two files is the signature of an
injected payload; a large delta spread across nearly every file is a
build-pipeline change.

Limits documented rather than implied: only files the format selector
accepts are compared, so a payload in a shell script or binary is invisible;
parse failures are reported and counted as changed rather than silently
treated as unchanged (deeply nested obfuscated code can exceed the
tree-sitter recursion limit, which happened on one real file); and shape
equality is not semantic equality.

Tests: 8 unit (identical trees, relative-path matching, added/removed/
changed, value-only edits unchanged, node-delta accounting, unparseable file
reported, non-directory rejected, determinism) and 9 CLI integration tests
including that renaming is not a structural change and that an injected
payload is detected in an otherwise stable release.
@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@copyleftdev, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 5 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 0cd87692-a2d0-402d-bd2c-8fa45adc1c49

📥 Commits

Reviewing files that changed from the base of the PR and between 6580a10 and 4801310.

📒 Files selected for processing (4)
  • docs/src/cmd-drift.md
  • vajra-cli/src/main.rs
  • vajra-cli/src/treediff.rs
  • vajra-cli/tests/tree_diff.rs

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@copyleftdev
copyleftdev merged commit 302276a into main Jul 30, 2026
5 checks passed
@copyleftdev
copyleftdev deleted the feat/tree-diff branch July 30, 2026 04:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

drift: structural version-to-version diff for source directories

1 participant