Skip to content

feat(sync): add the Python adapter - #991

Merged
ctawiah merged 1 commit into
mainfrom
ctawiah/sync-python-adapter-3bbf
Nov 9, 2026
Merged

ctawiah merged 1 commit into
mainfrom
ctawiah/sync-python-adapter-3bbf

Conversation

@ctawiah

@ctawiah ctawiah commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

Context

Python sources need static inspection of agents, prompts, tools, and constants, with literal edits and placeholder translation.

What changes

  • Add the shared code package: tree-sitter parsing with a time limit, string codecs, message lists, and placeholder translation.
  • Add the Python adapter. It finds resources through an import allowlist, follows static repository imports, and writes dictionaries and lists as Python literals.

Design notes

  • The adapter never imports or runs Python.
  • Dynamic, mutated, or unresolved values are read-only.

Review focus

  • Are the framework calls allowlisted narrowly?
  • Do imported edits target the dependency file?

Verification

  • make build
  • go test ./...
  • golangci-lint run ./...

Related changes

  1. refactor(sync): remove Phase 1 source references #1005: Phase 1 reference removal
  2. feat(sync): add projection and problem core #987: projection and problem core
  3. feat(sync): add the adapter core and the Markdown adapter #988: adapter core and Markdown
  4. feat(sync): add the structured document adapters #989: structured adapters
  5. This PR: Python adapter
  6. feat(sync): add the TypeScript and JavaScript adapters #992: TypeScript and JavaScript adapters
  7. feat(sync): add source descriptor storage #993: descriptor storage
  8. feat(sync): compile and publish repository sources #994: compile and publish
  9. feat(sync): detach descriptor-backed sources #1003: descriptor detach
  10. feat(sync): prepare repository source links #997: link preparation
  11. feat(sync): integrate repository sources with planning and link #995: planning and link
  12. feat(sync): add the link commands #998: link commands

🤖 Generated with Claude Code


Note

Overview
Adds tree-sitter dependencies and a new Python sync adapter that statically inspects .py sources (no Python execution) to project agents, prompts, tools, and constants for LaunchDarkly sync.

Introduces the shared adapter/code package for bounded parsing, Python/JS string codecs, message lists, {{name}} placeholder translation, import/constant helpers, and positional resource IDs. adapter.Document gains ReadOnly so values from generated or vendored dependency files stay non-writable.

The Python adapter uses a framework import allowlist and call rules to recognize LangChain, CrewAI, OpenAI clients, and similar patterns; it follows static repository imports, marks dynamic, mutated, or ambiguous values read-only, and encodes writes as Python literals. resolve.go records positional id selections with sibling-aware identity so index-based mappings stay stable when unnamed resources shift.

Reviewed by Cursor Bugbot for commit aaaccc6. Bugbot is set up for automated code reviews on this repo. Configure here.

@ctawiah
ctawiah added this pull request to stack #1000 October 30, 2026 20:15
@ctawiah
ctawiah force-pushed the ctawiah/sync-python-adapter-3bbf branch from 794bcb2 to 07ab9e4 Compare October 31, 2026 00:05
@ctawiah
ctawiah force-pushed the ctawiah/sync-python-adapter-3bbf branch 2 times, most recently from 9b0dc0f to b2e309e Compare November 3, 2026 00:29
@ctawiah ctawiah changed the title feat(sync): add the Python source adapter feat(sync): add the Python adapter Nov 3, 2026
@ctawiah
ctawiah removed this pull request from stack #1000 November 3, 2026 00:32
@ctawiah
ctawiah changed the base branch from ctawiah/sync-provider-profiles-3bbf to ctawiah/sync-structured-adapters-3bbf November 3, 2026 00:32
@ctawiah
ctawiah added this pull request to stack #1004 November 3, 2026 00:34
@ctawiah
ctawiah removed this pull request from stack #1004 November 3, 2026 00:53
@ctawiah
ctawiah added this pull request to stack #1006 November 3, 2026 00:53
@ctawiah
ctawiah force-pushed the ctawiah/sync-python-adapter-3bbf branch from b2e309e to aaeb409 Compare November 4, 2026 19:46
@ctawiah
ctawiah force-pushed the ctawiah/sync-python-adapter-3bbf branch 2 times, most recently from eb1bb51 to 3deaad9 Compare November 4, 2026 22:26

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale Bugbot comment from a previous run.

Comment thread internal/sync/adapter/python/python.go Resolved

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale Bugbot comment from a previous run.

Comment thread internal/sync/adapter/python/python.go Resolved

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale Bugbot comment from a previous run.

Comment thread internal/sync/adapter/python/python.go Resolved

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale Bugbot comment from a previous run.

Comment thread internal/sync/adapter/python/python.go Resolved
Comment thread internal/sync/adapter/code/code.go Resolved

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale Bugbot comment from a previous run.

Comment thread internal/sync/adapter/python/python.go Resolved
Comment thread internal/sync/adapter/python/python.go Resolved

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale Bugbot comment from a previous run.

Comment thread internal/sync/adapter/python/python.go Resolved
Comment thread internal/sync/adapter/python/python.go Resolved

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale Bugbot comment from a previous run.

Comment thread internal/sync/adapter/code/code.go Resolved

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale Bugbot comment from a previous run.

Comment thread internal/sync/adapter/python/python.go Resolved
Comment thread internal/sync/adapter/python/python.go Resolved

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale Bugbot comment from a previous run.

Comment thread internal/sync/adapter/python/python.go Resolved

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale Bugbot comment from a previous run.

Comment thread internal/sync/adapter/python/python.go Resolved
remaining--
}
}
return false

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Defaults use function-body scoping

Low Severity

Parameter defaults sit under function_definition, so pythonDeferredUse and pythonScopedName treat them like the function body. Python evaluates defaults at definition time in the enclosing scope, so a default can pick up a later module constant, miss an earlier class attribute, or get hidden by a parameter of the same name.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 995ab51. Configure here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed. pythonScopeChain and pythonDeferredUse skip a function for a node in a parameter default, through pythonInParameterDefault, so a default runs in the enclosing scope at definition time: it reads a module constant in module scope, misses a later constant, and reads a class attribute in a method. TestPythonParameterDefaultsRunInTheEnclosingScope covers the three cases.

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale Bugbot comment from a previous run.

})
for id := range ambiguous {
delete(instances, id)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Non-framework assignments drop client kinds

High Severity

pythonInstances treats any kind change as ambiguous, including an empty kind from self.client = None or client = mock(). Combined with class-wide self.client ids, a cleanup or mock assignment in any method deletes the client kind for the whole class, so chat.completions.create and messages.create calls are not collected.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 84c14fb. Configure here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in part, by design. A reset such as self.client = None in a cleanup does not change the kind of the client, so the instance walks skip a None, null, or undefined value and the requests of the class are collected. A mock assigned to the same name still hides the client, because the existing behavior and its test treat a mock as a value of another kind, and a static reader cannot tell which assignment runs. TestPythonClientResetsDoNotDropTheClientKind and TestTypeScriptClassFieldsDoNotHideImports cover a reset next to a request.

ordinals := map[string]int{}
code.Walk(tree.RootNode(), func(node *sitter.Node) {
if node.Kind() != "assignment" {
return

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Walrus bindings skip named resources

Medium Severity

Resource collection and pythonInstances only walk assignment nodes, not named_expression. A walrus such as agent := Agent(...) or client := OpenAI() is not named after the variable and is not recorded as a client instance, so follow-on client.chat.completions.create calls are missed. Constants already handle the walrus via pythonDeclaredVariable.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 84c14fb. Configure here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed. A walrus is an assignment for the resource walk, the instance walk, and the helpers that tell an assigned call from an unassigned one, through pythonAssignmentParts, so agent := Agent(...) names the agent after the variable and client := OpenAI() is a client. TestPythonWalrusBindingsNameResourcesAndClients covers both.

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale Bugbot comment from a previous run.

// a subscript, `items[0] = b = Agent()`, names nothing, so the inner
// name collects them.
if pythonNamedAssignment(node.Parent()) != nil {
return

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Walrus calls skipped by both walks

Medium Severity

pythonAssignmentTarget treats any identifier assignment in the parent chain as a value chain, so a walrus like x = [a := Agent()] is skipped as an “inner link.” The call walk then also skips it because pythonAssignedValue sees the walrus, and the agent is never collected.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 43573d3. Configure here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed. The inner-link check follows the assignment chain only, through pythonChainTarget: an outer assignment is a link when it holds the inner one as its whole value, so a walrus inside a list or a call, as in x = [a := Agent()], names its own call. TestPythonWalrusBindingsNameResourcesAndClients covers a walrus inside a list next to an unassigned call.

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale Bugbot comment from a previous run.

for parent := node.Parent(); parent != nil; parent = parent.Parent() {
switch parent.Kind() {
case "lambda":
return true

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Lambda defaults resolve as runtime uses

Medium Severity

pythonDeferredUse treats every name inside a lambda as running when the lambda is called. Python evaluates a lambda’s default arguments at definition time, like a function default, so those names should use the enclosing scope at that point. A default can pick up a later constant or import, or miss an unbound local that would fail at definition, and pythonInParameterDefault only covers function_definition.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d87328a. Configure here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed. pythonInParameterDefault covers a lambda too, so pythonDeferredUse and the alias visibility treat a default of a lambda as code that runs with the definition, in the enclosing scope at that point. TestPythonLambdaDefaultsRunAtDefinition covers a default before a later constant and one after an earlier constant.

if attribute, _ := pythonChainRoot(rest); (rootName == "self" || rootName == "cls") && attribute != "" {
return strings.Join(append(pythonClassScopes(node, source), attribute), ".")
}
return pythonScopedName(node, rootName, source, declared)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Class-name mutations skip class attributes

Medium Severity

pythonMutatedName only maps self.attr and cls.attr writes onto the class attribute. A mutation through the class name, such as Bot.items.append(...) or Bot.items[0] = ..., scopes only the root Bot, so Bot.items stays a writable constant after an in-place change.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d87328a. Configure here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed. pythonMutatedName maps a write through the class name, such as Bot.items.append(...) or Bot.names[0] = ..., onto the class attribute when the file declares it, so the attribute is read-only after an in-place change. TestPythonClassNameMutationsFreezeTheClassAttribute covers a mutating call and a subscript write.

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale Bugbot comment from a previous run.

}
}
}
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Lambda parameters hide enclosing names

Medium Severity

Lambda parameters are recorded with pythonQualifiedID, whose scope chain only includes function_definition and class_definition. A parameter of a nested lambda is therefore treated as a local of the enclosing function. That pre-binds the name for the whole function and hides a module constant of the same name, so a later agent or prompt in that function reads as dynamic even though Python only binds the name inside the lambda.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c9b6f69. Configure here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Not reproducible. The parameter walk matches the parameters node of a def, and tree-sitter gives a lambda a lambda_parameters node, so a lambda parameter is never recorded as a local of the enclosing function. pythonLocalBinding binds it inside the lambda only. TestPythonLambdaParametersBindOnlyInsideTheLambda pins this: a lambda body reads its parameter, and an agent after the lambda in the same function still reads the module constant.

if alias := value.ChildByFieldName("alias"); alias != nil {
return []*sitter.Node{alias}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Except aliases never bind names

Medium Severity

pythonBindingTargets handles except_clause like with_item, looking for a value field whose kind is as_pattern. Tree-sitter's except_clause has no value field, so except E as name never records a binding. A module constant of that name stays writable through the handler, and a write would edit the constant instead of treating the alias as dynamic.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c9b6f69. Configure here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Not reproducible. In the tree-sitter Python grammar that sync vendors (v0.25.0), except E as name parses as (except_clause value: (as_pattern ... alias: (as_pattern_target (identifier)))), so the value branch binds the alias. TestPythonExceptAliasesHideTheOuterConstant pins this at module level and in a function: an agent that reads the alias, inside or after the handler, is read-only.

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale Bugbot comment from a previous run.

frozen := value.Clone()
frozen.MarkReadOnly("mutated constants are read-only")
environment[name], values[name] = frozen, frozen
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Aliased imports ignore sibling mutations

Medium Severity

Mutation tracking freezes only the binding that was written. Two aliases of the same import, such as TEXT and ALSO, share one object, but TEXT.append(...) leaves ALSO writable with the pre-mutation value, so a publish can emit messages or constants that the file already changed.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b0cf4eb. Configure here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed. pythonSpreadMutations links the names that share one object, two imports that bind one value of another file and a declaration that binds a bare name to another constant, and spreads a mutation to every linked name, so ITEMS.append(...) freezes ALSO too, and a mutation through a local alias freezes its origin. TestPythonMutationThroughAnAliasFreezesEveryName covers both, with an untouched import that stays writable.

}
id := pythonQualifiedID(node, code.Text(name, source), source)
declarations = append(declarations, declaration{node: node, id: id, value: valueNode})
counts[id]++

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Global assignment skips module mutation

Medium Severity

A global or nonlocal assignment is recorded as a function-local declaration through pythonQualifiedID, so it does not freeze the module (or enclosing) constant. Sync can still treat that constant as a direct value and write the original literal after the function has rebound it.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b0cf4eb. Configure here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed. Declarations and binding targets take their id from pythonBindingID, which moves a name that the function declares global to the module and a name declared nonlocal to the nearest enclosing function, so the outer constant counts as reassigned and reads as read-only, and no local of the function is recorded. TestPythonGlobalAndNonlocalAssignmentsRebindTheOuterConstant covers a global, a nonlocal, and a plain local that still shadows.

if id := pythonMutatedName(node, owner, source, declared); id != "" {
mutated[id] = true
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Delete statements skip mutation tracking

Low Severity

The mutation walk handles assignments, walruses, and in-place methods, but not delete_statement. del ITEMS[0] or del MESSAGES[-1] leaves that collection writable, so a write-back can restore entries the source already removed.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b0cf4eb. Configure here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed. The mutation walk handles delete_statement: del ITEMS[0], del CONFIG["key"], PAIR[1], and del NAME mark their root constant mutated, so the constant is read-only and a write-back cannot restore a removed entry. TestPythonDeleteStatementsFreezeTheCollection covers a subscript, an attribute list, and a bare name, with an untouched list that stays writable.

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale Bugbot comment from a previous run.

// name collects them.
if pythonNamedAssignment(node.Parent()) != nil {
return
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Walrus prompts keep the outer name

Medium Severity

The prompt inner-link skip still uses pythonNamedAssignment, so any enclosing named assignment counts as a chain. A walrus prompt inside a list or call is collected under the outer variable instead of its own name. Framework calls already use pythonChainTarget for this, which only follows an outer assignment when the inner node is its whole value.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 197b4df. Configure here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed. The prompt walk skips an assignment only when it is a link of a chain, through pythonChainTarget, as the framework calls already did, and pythonPromptCalls leaves a call that a walrus inside the value binds to that name, so batch = [reply := client.chat.completions.create(...)] names the prompt reply once. TestPythonWalrusPromptsKeepTheirOwnName covers a walrus in a list and a chain.

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale Bugbot comment from a previous run.

if messages := code.MessageList(constant); messages != nil {
return messages
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Messages skip inner local bindings

Medium Severity

pythonMessages resolves a bare messages name through pythonConstant only, so it never applies pythonLocalBinding. A lambda parameter or comprehension target that reuses a module messages name is treated as that outer list, which is then writable.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 3e6c2d5. Configure here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed. pythonMessages checks pythonLocalBinding before it resolves a bare name, so a lambda parameter or a comprehension target that reuses a module messages name is read-only instead of the outer list. TestPythonMessagesSkipInnerLocalBindings covers a lambda parameter, a comprehension target, and a plain use that still resolves.

if pythonParameterName(parameter, source) == name {
return true
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Lambda defaults bind inner parameters

Medium Severity

pythonLocalBinding treats any use inside a lambda as a parameter when the name matches, including default values. Defaults run in the enclosing scope, so a default that shares a parameter name is marked dynamic instead of resolving the outer constant.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 3e6c2d5. Configure here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed. pythonLocalBinding skips a lambda when the use sits in one of its parameter defaults, and skips a comprehension when the use sits in the iterable of its first for clause, because both run in the enclosing scope. TestPythonLambdaDefaultsAndFirstIterablesSeeTheOuterName covers a default and a first iterable that resolve the module constant, next to a lambda body and a comprehension body that read their own binding.

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale Bugbot comment from a previous run.

value := constants[binding.name]
if value == nil {
value = code.UnresolvedImport(binding.module, binding.name, document.Path)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Imported mutated names look unresolved

Medium Severity

A dependency that reassigns or mutates a name stores nil under that id, so a later from … import of it becomes UnresolvedImport with ErrorImportUnresolved. The linked file still looks missing, and the initial literal is dropped instead of staying a read-only binding.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit dc79b19. Configure here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed. A file keeps the first literal of a name that it reassigns or mutates under code.RecordFrozen, and an import of that name binds the literal as read-only with the reason of the file, so the import resolves and blocks a write instead of reading as unresolved. The TypeScript adapter had the same gap and uses the same record. TestPythonImportsOfReassignedAndMutatedNamesAreReadOnlyBindings and TestTypeScriptImportsOfReassignedNamesAreReadOnlyBindings cover a reassigned string and a mutated list next to a plain import.

for _, scope := range scopes[:depth+1] {
names = append(names, scope.name)
}
return strings.Join(append(names, name), ".")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nonlocal skips the wrong function

Low Severity

A nonlocal assignment is rebound on the nearest enclosing function, even when that function does not declare the name. Python binds it on the nearest enclosing function that already has the name, so an intermediate empty function can hide the real reassignment and leave the outer constant writable.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit dc79b19. Configure here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed. In nonlocal mode pythonBindingID skips an enclosing function that does not bind the name itself, through pythonFunctionBinds (parameters and the assignment, walrus, loop, with, except, and unpacking targets of its own body), so the rebinding lands on the nearest function that has the name. TestPythonNonlocalBindsTheNearestFunctionThatHasTheName covers an empty middle function.

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale Bugbot comment from a previous run.

counts[id]++
}
}
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Defs and classes skip name bindings

Medium Severity

pythonConstants records assignments, parameters, and for/with/except/case targets, but a def or class never binds its name. A later function or class with the same name as a constant still resolves to the earlier literal, so an agent can look writable with the wrong instructions after that name is rebound. pythonFunctionBinds has the same gap, so nonlocal can also skip the function that actually owns the name.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit cfff96a. Configure here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed. A def or a class binds its name in the enclosing scope as a declaration without a value, so a later definition with the name of a constant makes the constant read-only and hides it from there on, and pythonFunctionBinds counts a nested def or class, so a nonlocal lands on the function that owns the name through a definition. TestPythonDefsAndClassesBindTheirNames covers a def, a class, and a nonlocal through a def.

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale Bugbot comment from a previous run.

value := *wildcard
value.Reason = fmt.Sprintf("star import cannot resolve %s statically", code.Text(node, source))
return &value
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Star import shadows function locals

Medium Severity

After pythonConstant misses a name, identifier lookup always falls through to the star-import wildcard. A function parameter or non-literal local already has a nil constants entry, so that binding is reported as an unresolved import (with the wildcard’s file and 0,0 span) instead of a dynamic local. Sync then hints about linking a dependency for a name that is local to the function.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 47ad600. Configure here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed. Before the star-import fallback, the identifier lookup checks the scoped name: an entry without a value, which a parameter, a target, or a binding without a literal leaves, makes the name a dynamic local (dynamic expression has no safe inverse, no import code), and only a name with no binding in scope falls through to the wildcard. TestPythonStarImportDoesNotSupplyFunctionLocals covers a parameter, a dynamic local, and a name that the star import does supply.

}
}
visit(function.ChildByFieldName("body"))
return bound

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nonlocal skips imported enclosing names

Medium Severity

pythonFunctionBinds treats parameters, assignments, loops, with/except aliases, and unpacking as bindings, but not from … import (or import) names. nonlocal therefore skips an enclosing function that only imported the name and rebinds the module constant instead, so a nested assignment can freeze or overwrite the wrong binding.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 47ad600. Configure here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed. pythonFunctionBinds counts the names that an import or a from ... import statement binds in the function, through pythonImportedNames, so a nonlocal lands on the function that imported the name instead of the module constant. A global or nonlocal assignment also counts as a rebinding of its target, because it runs when its function runs, so the target is read-only even when it is the only assignment. TestPythonNonlocalBindsAnImportedEnclosingName covers a function-local import with a nonlocal in a nested function, and checks that the module constant stays writable.

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale Bugbot comment from a previous run.

return candidate
}
}
return name

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nested global skips the wrong name

Medium Severity

pythonBindingID honors global and nonlocal when recording assignments, but reads and mutations resolve through pythonScopedName, which only walks enclosing declarations. In a nested function that declares global NAME while an outer function also binds NAME, a use or NAME.append(...) still attaches to the outer local instead of the module name, so the wrong constant is read and the actually mutated object can stay writable.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 8e15573. Configure here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed. pythonScopedName asks pythonDeclaredScopeID first: when the innermost function declares the name global or nonlocal, a read or a mutation resolves to the scope that pythonBindingID gives the writes, so NAME.append(...) under global NAME freezes the module constant and a read skips the local of the enclosing function. TestPythonGlobalStatementsScopeReadsAndMutations covers both next to an untouched outer local.

for _, name := range names[1:] {
link(names[0], name)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Aliased imports miss mutation spread

Medium Severity

pythonSpreadMutations treats two imports as the same object only when they share a pointer. An alias in the dependency such as ALSO = ITEMS is stored as a Clone(), so importing both names yields two nodes that still point at one literal. Mutating one freezes only that name; the alias stays writable and a write-back can overwrite the mutated collection.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 8e15573. Configure here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed. A file records ALSO = ITEMS as an alias through code.RecordAlias when the alias is assigned once, and an import resolves the alias to its origin through code.AliasOrigin, so both names import one value and pythonSpreadMutations links them by pointer. The TypeScript adapter does the same for export const ALSO = ITEMS. TestPythonImportedAliasesShareMutations and TestTypeScriptImportedAliasesShareMutations cover a mutation through the origin that freezes the alias.

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale Bugbot comment from a previous run.

} else {
switch pythonScopeStatement(parent, name, source) {
case "global_statement":
return name

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nonlocal search follows enclosing global

Medium Severity

When a nested function has nonlocal for a name, pythonBindingID still returns on an intervening function's global statement and treats the write as module-level. That global only affects that middle function, so the search should skip it and keep looking for the nearest enclosing local binding. Reads and mutations that go through pythonDeclaredScopeID follow the same path, so the wrong constant can be frozen or selected for a write.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit aabcf69. Configure here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed. In nonlocal mode pythonBindingID skips a function between that declares the name global, because that statement binds the name for that function only and leaves it without a local, so the search continues to the nearest enclosing function that binds the name. Reads and mutations through pythonDeclaredScopeID follow the same path. TestPythonNonlocalSkipsAGlobalOfAFunctionBetween covers a global in the function between and checks that the module constant stays.

case "assignment", "augmented_assignment", "named_expression":
if left, _ := pythonAssignmentParts(node); left != nil && left.Kind() == "identifier" && code.Text(left, source) == name {
bound = true
return

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Augmented assignment skipped for nonlocal

Medium Severity

pythonFunctionBinds lists augmented_assignment as a local binding, but it asks pythonAssignmentParts for the target, and that helper only handles assignment and named_expression. A function whose only write to the name is += (or another augmented op) is treated as not binding it, so a nested nonlocal skips that function and lands on the wrong scope.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit aabcf69. Configure here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed. pythonFunctionBinds reads the target of an augmented_assignment from its left field, so a function whose only write is NAME += value binds the name and a nested nonlocal lands on it. TestPythonAugmentedAssignmentsBindInTheirFunction covers a parameter with an augmented write and a nonlocal below it.

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale Bugbot comment from a previous run.

// The nil entries shadow an import of the same name here and in
// every later literal.
values[item.id], environment[item.id] = nil, nil
continue

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Mutated aliases skip frozen records

Medium Severity

ALSO = ITEMS is meant to record an alias and freeze both names when ITEMS is mutated, but mutated names never enter environment, so the alias evaluates as non-literal and the loop continues before RecordAlias or RecordFrozen. An import of ALSO from that file then reads as unresolved instead of a read-only copy of the origin.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a04cc4c. Configure here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed. The alias record of ALSO = ITEMS comes before the literal check, so a mutated or reassigned origin, which has no literal in the environment, still records the alias, and an import of the alias resolves through code.AliasOrigin to the frozen first literal of the origin as read-only. TestPythonImportOfAnAliasOfAMutatedNameIsReadOnly covers an alias of a mutated list and an alias of a reassigned string.

code.Walk(root, func(node *sitter.Node) {
name, valueNode := pythonDeclaredVariable(node)
if name == nil || valueNode == nil {
return

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bare annotations fail to shadow

Medium Severity

A function annotation with no value (TEXT: str) makes that name local in Python, but pythonDeclaredVariable drops assignments whose right-hand side is missing, and identifier targets are not recorded as binding targets either. Uses of that name still resolve to the outer constant, so a prompt write can edit the module value the function does not actually read.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a04cc4c. Configure here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed. A bare annotation, TEXT: str, binds its name in its scope as a declaration without a value, so a use in the function is dynamic instead of the module constant. An assignment of the name is the binding when there is one, so the annotation does not count as a reassignment. TestPythonBareAnnotationsBindTheirName covers an annotation alone, an annotation with a later assignment, and the module constant that stays.

if alias := value.ChildByFieldName("alias"); alias != nil {
return []*sitter.Node{alias}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Except-star aliases are not bound

Low Severity

Binding collection handles except_clause aliases but not except_group_clause (except*). A name bound in except* ExceptionGroup as err never shadows an outer constant, so later reads can still resolve and write that constant.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a04cc4c. Configure here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The grammar that sync vendors parses except* ExceptionGroup as err as an except_clause, so the alias was already bound. The binding targets now also accept an except_group_clause, for a grammar that gives the group clause a kind of its own. TestPythonExceptGroupAliasesBindTheirName covers an except* alias that hides a module constant.

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale Bugbot comment from a previous run.

}
if id := pythonMutatedName(node, code.Text(target, source), source, declared); id != "" {
mutated[id] = true
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Function += and del freeze outer names

Medium Severity

pythonDeclaredVariable only treats plain assignment and walrus as declarations, so a function-local NAME += … or del NAME is not recorded as a local binding. The mutation walk then attributes that write to the enclosing constant through pythonMutatedName. Python treats both as locals of the whole function (UnboundLocalError without global); the adapter instead freezes the module (or outer) constant and blocks writes to it. pythonFunctionBinds already counts += for nonlocal, so this is incomplete for reads and mutations.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit edee320. Configure here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed. In a function, NAME += value and del NAME bind the name as a local when the function binds it in no other way, so the write hides the outer constant instead of freezing it, and a function that already binds the name, through an import, an assignment, or a parameter, has that local mutated. A global statement keeps the write on the module constant, and at module level and in a class body the write still mutates the constant of that scope. TestPythonAugmentedAndDeleteWritesInAFunctionBindLocals covers an augmented write, a delete, and a delete under a global statement.

return value.Clone()
}
return code.ShadowedImport(constants, scoped, use)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Class body misses earlier outer names

Medium Severity

pythonScopedName prefers a class attribute whenever the class binds that name anywhere, and pythonConstant only falls back to a shadowed import when the use sits before that assignment. Class bodies run in order and should still see the enclosing module constant until the class binds the name. A resource in the class body that reads the name before the class assignment therefore becomes unresolved instead of using the module value. Imports are handled; ordinary module constants are not.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit edee320. Configure here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed. pythonConstantWith resolves a use in a class body that comes before the class binds the name through the ids without that class attribute, so the use reads the enclosing module constant, as Python does in a class body that runs in order. A use after the class assignment reads the class attribute. TestPythonClassBodiesSeeTheModuleConstantBeforeTheClassBindsIt covers both.

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale Bugbot comment from a previous run.

return pythonConstantWith(node, name, use, source, constants, except)
}
}
return code.ShadowedImport(constants, scoped, use)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Class body skips outer name

Medium Severity

pythonConstantWith returns as soon as the class-scoped entry is nil, so it never runs the later fallback that should read the enclosing name. A class-body use of TEXT before a later class binding, or beside a non-literal/reassigned class TEXT, does not see the module constant even though that is how Python resolves it.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f66f19c. Configure here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed. The constants record where the first declaration of each name starts, and pythonConstantWith falls back to the enclosing name when a class-body use comes before the class binds the name at all, before it looks at the class entry, so a later literal, a reassigned attribute, and a dynamic attribute all leave the earlier use to the module constant. TestPythonClassBodiesSeeTheModuleConstantBeforeTheClassBindsIt covers the three cases.

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale Bugbot comment from a previous run.

declarations = append(declarations, declaration{node: node, id: id, value: nil})
counts[id]++
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bare annotations bind in class bodies

Medium Severity

A bare annotation is recorded as a binding in every scope, including class and module bodies. In those scopes it only fills __annotations__ and does not hide an outer name, so a later use can miss the module constant and become read-only. Function bodies are the case where this hiding is correct.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 1127d9d. Configure here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed. A bare annotation binds its name only in a function body, where Python makes the name local. In a module or a class body the annotation fills the annotations and binds nothing, so a later use still reads the module constant. TestPythonBareAnnotationsBindTheirName now covers a class body and a module annotation that bind nothing next to the function cases.

origin := pythonScopedName(item.value, code.Text(item.value, source), source, declared)
if origin != item.id && declared(origin) {
link(item.id, origin)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Alias mutations ignore later reassignment

Medium Severity

pythonSpreadMutations links every identifier assignment to its origin, including names that are later reassigned. A mutation of one origin then freezes unrelated constants that only shared the name after a later assignment. Alias recording already requires a single unmoved assignment.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 1127d9d. Configure here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed. pythonSpreadMutations links an identifier assignment to its origin only when the name is assigned once and not moved by a global or nonlocal statement, the same rule as the alias record, so a name that is assigned again does not spread a mutation to the constant it held first. TestPythonReassignedAliasesDoNotSpreadMutations covers an alias that is assigned again and then mutated.

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale Bugbot comment from a previous run.

// `a = b = "text"` nests the second assignment as the value of the first.
if right := node.ChildByFieldName("right"); right != nil {
return pythonValue(right, source, constants)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Walrus values skipped in constant reads

Medium Severity

pythonValue follows parenthesized expressions and chained = assignments but does not follow a named_expression. A walrus on the right of an assignment or inside a list or dict therefore reads as dynamic, so static literals such as NAME = (OTHER := "text") and [x := "hello", "world"] become read-only instead of writable constants. Resource collection already unwraps this form through pythonUnwrap.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 5f1dd05. Configure here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed. pythonValue follows a named_expression to its value, as the resource collection does, so NAME = (OTHER := "text") and [x := "hello", "world"] read as writable constants. TestPythonWalrusValuesReadAsConstants covers a walrus as the value of an assignment and a walrus inside a list.

Add the shared tree-sitter helpers, string codecs, and placeholder translation. Add the Python adapter: it finds agents, prompts, tools, and constants through an import allowlist, follows static repository imports, and never runs Python.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

There are 37 total unresolved issues (including 36 from previous reviews).

Fix All in Cursor

Reviewed by Cursor Bugbot for commit aaaccc6. Configure here.

fields["content"] = adapter.ReadOnly(int(node.StartByte()), int(node.EndByte()), "message content is not static")
}
return &adapter.Node{Fields: fields}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Named messages in lists not resolved

Medium Severity

pythonMessage only reads inline tuples, dicts, and constructors, so a literal message list that names a constant such as from_messages([SYSTEM, HUMAN]) never loads that constant. pythonValue and a whole-list identifier already follow those names, so this form becomes read-only instead of a writable prompt.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit aaaccc6. Configure here.

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.

2 participants