Agent guidance for codellm-devkit/codeanalyzer-python.
Respect the global ~/.claude/CLAUDE.md instructions strictly.
AGENTS.md is a symlink to this file — one source of truth for every agent tool.
- Never add AI/Claude authorship anywhere — not in commit subjects or bodies
(no
Co-Authored-By, no "Generated with …", no 🤖 trailer), not in PR/issue text, code comments, docs, or any file written to disk. This is absolute and overrides any tool suggestion or template. - Use Conventional Commits (
type(scope): summary). - This repo's own
CLAUDE.md/AGENTS.mdand.claude/SCHEMA_DECISIONS.mdare tracked past a global gitignore via!-negations in.gitignore; keep those negations if you touch the ignore file.
codeanalyzer-python emits canonical schema v2 (schema_version 2.0.0): one
additive Code Property Graph (CPG) tree, exposed as four gated analysis levels
(-a 1|2|3|4) across two projections. Read .claude/SCHEMA_DECISIONS.md for the
decision log and docs/superpowers/specs/2026-07-07-schema-v2-four-levels-design.md
for the full model rationale. This section is the short version so a future agent
does not re-derive it.
There is one tree, grown one layer deeper per level. Each level only adds
nodes and edges — nothing is removed or renamed between levels. The single
exception is one sanctioned refinement: a call node's callee goes null → id
when the call graph resolves it (L1→L2). Hence the monotonicity invariant, which
is a CI gate:
analysis.json(-a 1) ⊆ analysis.json(-a 2) ⊆ analysis.json(-a 3) ⊆ analysis.json(-a 4)
(superset modulo the callee: null→id refinement, and the DDG widening where L4
adds prov:["points-to"] edges over L3's prov:["ssa"] subset).
The payload root is the Analysis envelope (codeanalyzer/schema/py_schema.py):
Analysis # envelope: schema_version, language, max_level,
│ # analyzer{name,version}, k_limit (L3+ only), application
└─ application (PyApplication) # id, kind:"application"
├─ symbol_table: {<file>: PyModule} # the node tree
│ PyModule → id, kind:"module", source, types{…}, functions{…}
│ PyClass → id, kind:"class", span, callables{…}, types{…}
│ PyCallable → id, kind, span, body{…}, callables{…}, types{…},
│ cfg[], cdg[], ddg[], summary[]
├─ call_graph: [PyCallEdge] # {src, dst, prov, weight} — the list name IS the type
├─ external_symbols: {<id>: PyExternalSymbol} # edge-endpoint id homes (L2)
├─ param_in: [ParamEdge] # cross-function overlay, app scope (L4)
└─ param_out: [ParamEdge] # cross-function overlay, app scope (L4)
Intra-callable graphs (cfg/cdg/ddg/summary) hang off each callable; the
truly cross-function overlays (call_graph/param_in/param_out) live at
application scope because their endpoints span callables.
Keystone containment vocabulary (issue #98). The containers use the shared
canonical names — PyModule.types/.functions, PyClass.callables/.types,
PyCallable.callables/.types — never per-language renames, so one SDK model
set parses every analyzer's output. (The historical classes/methods/
inner_classes names are gone as of 1.0.0.)
No dangling edge endpoints. Every call_graph endpoint joins the id space:
declared callables by their can:// tree id, imported/builtin targets by a
can://python/<app>/@external/<module>/<name> id homed in
application.external_symbols (keyed by that id, kind:"external").
-
Durable
can://ids (codeanalyzer/schema/ids.py), for every node at or above a callable:can://python/<app>/<file>/<type-path>/<callable-sig><app>=--app-name(default: input dir name);<file>= the symbol-table key (relative POSIX path, and it contains/);<type-path>= nested class names joined by/;<callable-sig>=name(argnames). Ids are opaque handles — read a node's explicit fields, do not delimiter-split. -
LOCAL ordinal ids (< callable), used as
bodymap keys and as every intra-callablecfg/cdg/ddg/summaryedge endpoint:"line:col"for real statements,"@entry"/"@exit"for the CFG bookends,"@formal_in:0"/"@formal_out"for formals, and"<callsite>/actual_in:0"/"<callsite>/actual_out"for actuals (parented to their call site). The bijection(signature, int node_id) ↔ (can:// id, local id)is built once bycodeanalyzer/dataflow/identity.pyand feeds both projections, keeping them in lockstep. -
GLOBAL ordinal ids,
"<callable-id>@<local>"— the fully addressable form used for Neo4jPyCFGNodemerge keys and for cross-callable edge endpoints (e.g.param_in.src = can://…@16:2/actual_in:0). -
sourceonce per module (PyModule.source); every node's text is aspanslice.Spancarriesstart/endas[line, col]plusbytes:[lo,hi]utf-8 offsets, soget_method_body=module.source[span.bytes]. The old per-callablecodefield is gone.
| Level | Grows the tree with | Adds edges |
|---|---|---|
L1 -a 1 |
callables + call nodes in body (callee: null) |
— |
L2 -a 2 |
callee: null→id backfill |
call_graph |
L3 -a 3 |
rest of body (statements) + @entry/@exit |
cfg, cdg, ddg (syntactic, prov:["ssa"]) |
L4 -a 4 |
synthetic param vertices (@formal_in/out, …/actual_in/out) |
param_in, param_out, summary, + ddg (semantic, prov:["points-to"]) |
- L3 vs L4 DDG.
defuse.pyproduces DDG edges in one pass from two rules: (a) textual/access-path interference, (b) may-alias for suffixed paths. L3 emits only rule (a) — the oracle is bypassed (prov:["ssa"]). L4 keeps rule (a) and adds the alias-derived edges rule (b) contributes with the oracle live (prov:["points-to"]). L4 only widens the set, so monotonicity holds. - L4 points-to oracle = Scalpel.
ScalpelAliasOracle(codeanalyzer/dataflow/scalpel_oracle.py) consumespython-scalpel's solved SSA + copy/const state (it never forks the solver) as a copy-closure union-find over access paths, behind the frozenmay_alias(path_a, path_b) -> boolinterface.python-scalpelis an optional dependency: if it is absent or a build/query fails, the analyzer falls back to the totalTypeBasedAliasOracle(alias.py) and degrades, never raising. The type-based oracle is the sanctioned fallback, not the shipping default. - CLI gating (
codeanalyzer/__main__.py):-amax is 4;--graphs sdgrequires-a 4(a flag error below that);cfg,dfg,pdgrequire-a 3;--graph-field-depth(thek_limit) is valid at L3+.
analysis.json— the tree itself (theAnalysisenvelope above), also available as gzip'd msgpack.- Neo4j (
codeanalyzer/neo4j/) — a near-identity projection, keyed on the samecan:/// global ordinal ids: containment → typedPY_HAS_*/PY_DECLARESedges (PY_HAS_MODULE,PY_DECLARES,PY_HAS_METHOD,PY_HAS_CALLSITE,PY_HAS_CFG_NODE, …); overlays → typedPY_*relationships (PY_CALLSwithweight/prov,PY_CFG_NEXT,PY_CDG,PY_DDGwith aprovproperty,PY_PARAM_IN,PY_PARAM_OUT,PY_SUMMARY). CFG statements and param vertices share onePyCFGNodelabel, distinguished bykind. External call targets are:PyExternalghosts merged on theircan://…/@external/…id(same key as the JSONexternal_symbols). Relationship identity:PY_DDGmerges per(var, prov)andPY_CFG_NEXTperkindvia the internal_kdiscriminant — a plain endpoint-pair MERGE would collapse legitimately-distinct edges (per-variable dependences, a conditional's true/false pair). Neo4j is always full-depth —--emit neo4jcombined with-a/--graphsis an explicit error. The vocabulary isPY_-prefixed by design (per-language namespacing in a shared graph DB). Neo4jSCHEMA_VERSION=2.0.0(neo4j/schema.py).
The analyzer is the provider: it emits the SDG substrate only. The backward
slicer (dataflow/slicing.py) is internal — the L3/L4 validation gate, not a
product surface. Taint is the SDK's job: there is no taint_flows section,
by design (labeled SDG reachability + source/sink model packs live in the
cross-language CLDK SDK).
schema/— v2 Pydantic models (py_schema.py),can://id construction (ids.py), id assignment (assign_ids.py,call_graph_ids.py), per-level body builders (l1_body.py,l2_callees.py).syntactic_analysis/— the symbol table (symbol_table_builder.py): L1 tree.semantic_analysis/— the call graph (call_graph.py,jedi/pycg/codeqlbackends): L2.dataflow/— the analysis kernels:cfg.py,dominance.py,defuse.py,pdg.py,sdg.py,summaries.py,alias.py(type-based oracle),scalpel_oracle.py(L4 points-to oracle),access_paths.py,scc.py,slicing.py(internal),identity.py(the id bijection),builder.py(wires IR → v2 emission). L3 = intraprocedural CFG/CDG/DDG; L4 = interprocedural SDG.neo4j/— the graph projection (project.py,rows.py,cypher.py,bolt.py, declarative catalog + DDL inschema.py)..claude/SCHEMA_DECISIONS.md— the authoritative decision log (level 3/4 contract divergences, Neo4j namespacing, the Scalpel Stage-0 verdict).
Every vX.Y.Z tag makes the release workflow (.github/workflows/release.yml) auto-post an
announcement to the Announcements discussion (this repo, and the org mirror in
codellm-devkit/.github). That body is machine-generated from PR titles — it mis-categorizes
changes, includes chore(release) noise, and buries breaking changes.
When you're working in this repo, check whether the latest release's announcement still needs cleanup, and if so, fix it:
- Find it.
gh release view --json tagName,publishedAt; then list recent discussions viagh api graphql(repository → discussions), match categoryAnnouncementswhose title carries this release's version (the auto-post titles itvX.Y.Z; a cleaned-up one is📣 New Release: codeanalyzer-python X.Y.Z). Keep the discussion nodeidand read itsbody. - Skip if already done. If the body starts with
<!-- cleaned-up -->(or already reads as a clear, human-written announcement), do nothing. - Otherwise rewrite it into a clear, user-facing announcement, grounded in
CHANGELOG.mdand the referenced PRs/diff (not the auto-grouping — verify each change; never invent anything):- breaking changes first, each with a one-line migration step;
- plain-language highlights (what it does, not the PR title);
- upgrade line:
pip install -U "codeanalyzer-python==X.Y.Z"; - links to the GitHub release and
CHANGELOG.md.
- Update in place. Edit the discussion with the GraphQL
updateDiscussionmutation (don't open a new one): set the title to📣 New Release: codeanalyzer-python X.Y.Z, prepend<!-- cleaned-up -->to the body, and mirror the same title and body to the org discussion. This task only reads code and edits Discussions — it makes no commits.