Skip to content

Add native API versioning and compatibility guards - #126

Open
scorsin-oai wants to merge 1 commit into
Snapchat:mainfrom
scorsin-oai:simon/260723-native-api-versioning
Open

Add native API versioning and compatibility guards#126
scorsin-oai wants to merge 1 commit into
Snapchat:mainfrom
scorsin-oai:simon/260723-native-api-versioning

Conversation

@scorsin-oai

Copy link
Copy Markdown
Collaborator

Description

This change adds support for runtime versioning. It is designed to allow TS code to safely drift apart from its underlying native runtime.

The key concepts are as follow:

  • Types, functions, properties etc... can be annotated with @Version() and an arbitrary int version
  • User can check for runtime version using isVersionAtLeast().
  • The runtime bundles a version number.
  • The TS compiler checks for these versions and make compilation fail when using a versioned type that is not properly guarded using isVersionAtLeast()

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • Documentation improvement
  • Performance optimization
  • Test improvement
  • Other (please describe)

Testing

  • Tests pass locally (bazel test //...)
  • Added/updated tests for changes (if applicable)
  • Tested on multiple platforms (iOS/Android/Web/macOS as applicable)
  • Manual testing performed (describe below)

Testing Details

Checklist

  • Code follows project style guidelines
  • Documentation updated (if needed)
  • No breaking changes (or documented in description)
  • Commit messages follow conventional format
  • No secrets, API keys, or internal URLs included

Related Issues

Additional Context

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.snap.valdi.empty" />

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Semgrep identified an issue in your code:
This literal might contain a Snapchat internal reference that should not be committed to open-source repositories.

Fix: Please replace / remove the string to avoid committing it to open-source repositories.

To resolve this comment:

✨ Commit fix suggestion
  1. Replace the internal package name in the manifest with a neutral open-source identifier that does not include .snap, for example change package="com.snap.valdi.empty" to something like package="com.example.valdi.empty".
  2. Keep the new package value consistent with the module’s intended public namespace so Android tooling still treats it as the app/package identifier.
  3. Alternatively, if this manifest is only a placeholder or test fixture, remove the package attribute entirely when it is not required by the file’s purpose.
💬 Ignore this finding

Reply with Semgrep commands to ignore this finding.

  • /fp <comment> for false positive
  • /ar <comment> for acceptable risk
  • /other <comment> for all other reasons

Alternatively, triage in Semgrep AppSec Platform to ignore the finding created by internal-sensitive-strings.

You can view more details about this finding in the Semgrep AppSec Platform.

@github-actions github-actions Bot added area/runtime Valdi runtime (C++/native) area/compiler Valdi compiler area/build-system Bazel build rules and config labels Jul 23, 2026
@github-actions

Copy link
Copy Markdown

Sensitive Files Detected

🔧 Build rules — Affects build rules for all Valdi consumers.

This is an automated notice. A maintainer will review after import.

@github-actions

Copy link
Copy Markdown

🎉 Bazel & CI Test Results

Test Suite Result
API Surface Check ✅ success
Valdi Smoke Tests ✅ success
Snapshot Tests ✅ success
Linux: Build Compiler ✅ success
Linux: C++ Tests ✅ success
Linux: Build & Export ✅ success
macOS: C++ & Platform Tests ✅ success

All Bazel configuration and CI tests passed!

The build system and core tooling are working correctly.

🚀 Bazel remote cache is now enabled - future builds will be faster!

Workflow: Valdi CI

@scottthompsonsc scottthompsonsc left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Notes from reading through the versioning validator. Three findings, all about @Version propagation and guard narrowing. None look covered by VersioningValidator.spec.ts, so I may be misreading the intended semantics in places. Flagging as questions rather than blockers.

Comment on lines +156 to +163
for (const declaration of declarations) {
const version = this.getVersion(declaration);
if (version !== undefined) {
return version;
}
}

return undefined;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A member's @Version is read without its container's, so uses of it are not guarded

getVersionFromSymbol resolves the required version by calling getVersion(declaration) on the member declaration itself. For a member with no annotation of its own that returns undefined, even when the containing interface or class carries @Version(N), so validatePropertyAccess and getRequiredVersionForCall see no requirement and allow the access unguarded.

// @Version(5)
export interface Renderer {
  draw(): void;
}

function render(r: Renderer) {
  r.draw(); // no diagnostic, but this only exists at apiVersion >= 5
}

This is the direction that fails unsafely: the call compiles and then hits a missing native implementation on an older runtime. validateContainerDeclaration already propagates the container version to heritage clauses and to property members, so the propagation exists for declaration sites but not for use sites.

Is container-level @Version meant to be inherited by members? If so this lookup needs to walk to the containing declaration. If not, it might be worth rejecting @Version on a container outright, so the annotation cannot read as covering its members.

Separately, the loop returns the first declaration that has a version rather than the maximum across declarations, which would matter for merged interface declarations.

Comment on lines +283 to +285
if (ts.isMethodDeclaration(node) || ts.isGetAccessorDeclaration(node) || ts.isSetAccessorDeclaration(node)) {
return this.getVersion(node);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Class methods do not inherit their class's @Version during signature validation

getDeclarationVersion returns getVersion(node) for a method or accessor without consulting the parent, and validateContainerDeclaration skips function-like members, so a class method is only ever validated against its own annotation.

// @Version(5)
export class Foo {
  bar(): SomeV5Type { ... } // "Type 'SomeV5Type' requires @Version(5) on the containing declaration"
}

effectiveDeclarationVersion resolves to undefined here, so validateSignature reports a diagnostic for a signature the class's own version already covers, and every method has to repeat the annotation.

Interface methods are not affected, since isFunctionLikeDeclaration is false for MethodSignature and they keep going through validateContainerDeclaration to pick up memberVersion. That asymmetry between class and interface methods looks unintended.

this.validateCallExpression(node, currentVersion);
}

ts.forEachChild(node, (child) => this.visit(child, currentVersion));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Version guards only narrow inside if statements, so && and ternary guards fall through

visitVersionCondition handles && short-circuiting, but its only caller is visitIfStatement. Everywhere else visit reaches this forEachChild and descends into both sides of a &&, and both branches of a ternary, with the outer currentVersion, so the guard is never applied.

const canDraw = isVersionAtLeast(42) && model.v42Prop;
return isVersionAtLeast(42) && model.v42Prop;
{isVersionAtLeast(42) && <NewComponent />}
isVersionAtLeast(42) ? newApi() : oldApi();

The JSX form is the one I would expect to hurt most, since {cond && <Component />} is the usual way to render conditionally. Every isVersionAtLeast case in VersioningValidator.spec.ts is an if condition, so this reads as untested rather than intentional.

Routing && binary expressions through visitVersionCondition from visit, plus a ts.isConditionalExpression case that visits whenTrue with the merged version, would cover these. !isVersionAtLeast(N) followed by an early return is a related gap.

attrs = {
"deps": attr.label_list(
mandatory = True,
cfg = "exec",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is cfg = "exec" intended on deps?

deps here only supplies ValdiModuleInfo.intermediates, which are compiler outputs rather than tools this rule runs, so the exec transition looks unnecessary. For a consumer that also depends on those modules in the target configuration it would build the module graph a second time in the exec configuration. Nothing instantiates valdi_compilation_metadata yet so this is inert today, just cheaper to settle now than after the first consumer lands.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes is it. cfg = exec is used for all valdi modules output. If you don't use it, modules might be built twice

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/build-system Bazel build rules and config area/compiler Valdi compiler area/runtime Valdi runtime (C++/native)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants