# 0.3.0 decisions log

**Dev branch only.** 0.3.0 never ships to PyPI. This log, together with the
fixtures in `tests/fixtures/py_smoke/`, is the input to the 0.4.0 Rust spec.
Fixtures tell Rust what to do. This file tells Rust why.

## Entry format

```
## YYYY-MM-DD: <topic>
**Category:** [SCOPE-DRIVEN] | [PRINCIPLED]
**Decision:** one sentence
**Considered:** alternatives rejected
**Why this:** rationale for Python 0.3.0
**Rust implication:** what 0.4.0 should do
```

- **[SCOPE-DRIVEN]** = Python choice was forced by stdlib/regex/scope. Entry is
  directive: "Rust should do X instead." Gives Rust permission to diverge.
- **[PRINCIPLED]** = semantic decision that stands on its merits. Entry is
  contextual: "decided X because Y; Rust inherits or chooses." Contracts
  Rust honours unless it has a reason to break them.

Entries below were seeded from the 0.3.0 planning session on 2026-04-16. New
entries get added chronologically as implementation surfaces decisions.

---

## 2026-04-16: ID string shape
**Category:** [SCOPE-DRIVEN]
**Decision:** `py_method_{class_id}_{method_name}`, which expands to
  `py_method_py_class_py_mod_<path>_<Class>_<method>`. Method IDs are rooted
  off the class ID rather than flat mod+class+name composition.
**Considered:** flat `py_method_{mod_id}_{Class}_{method}` (less nesting but
  no explicit hierarchy); arbitrary opaque IDs (doesn't match 0.2.0 scheme).
**Why this:** hierarchy is explicit in the ID itself. Consistent with existing
  `py_class_{mod_id}_{Name}` pattern in 0.2.0. The nested type prefix is ugly
  but survives round-tripping through flow files.
**Rust implication:** redesign IDs. The Python scheme is path-encoded strings
  because 0.2.0 started that way and 0.3.0 extends additively. Rust should pick
  one of: (a) typed enum `NodeId::Method { module, class, name }` with
  serialisation at system boundaries, or (b) opaque `NodeId(u64)` with a side
  metadata table. Lean: (a) internally, (b) for serialised format. Fixtures
  assert structural relationships; they do not bind Rust to string-IDs.

---

## 2026-04-16: Node type vocabulary
**Category:** [SCOPE-DRIVEN]
**Decision:** Keep `py_package` / `py_module` / `py_class` / `py_function` /
  `py_method` / `py_route` etc. Language prefix is part of the type string.
**Considered:** dropping `py_` and making language a separate node property.
**Why this:** 0.2.0 already uses language-prefixed type strings across 20
  analyzers (go_func, rust_fn, py_class, ...). Cross-cutting analyzers also
  emit un-prefixed types (route, cli_command, grpc_method). Changing the
  convention in 0.3.0 would break every analyzer and every tool. Out of scope.
**Rust implication:** drop the language prefix. `language` becomes a property
  on the node (`node.language = Python`). Types become `Method`, `Function`,
  `Class`, `Module`, `Package`. Cleaner, avoids per-language type explosion,
  makes cross-language queries natural (`all classes regardless of language`).

---

## 2026-04-16: Calls confidence — binary only
**Category:** [PRINCIPLED]
**Decision:** A `calls` edge exists iff the target is statically resolvable.
  Unresolvable call sites drop silently. No confidence tiering on call edges.
**Considered:** three-tier confidence (Certain / Likely / Possible), separate
  edge types (`calls_definite` / `calls_probable`).
**Why this:** 0.3.0 has no type inference. "Likely" and "Possible" require
  either type hints (inconsistent in Python) or dynamic dispatch analysis
  (scope creep). Best-effort binary matches the tool we have. The *node*
  confidence system in 0.2.0 (strong/medium/weak) already exists; duplicating
  it on edges adds noise without new signal at this tier.
**Rust implication:** consider three-tier confidence as a property on edges.
  Tree-sitter + a simple type-hint resolver can upgrade many "Possible" sites
  to "Likely". Fixture covers a drop case (`u.login()` in do_login) — Rust
  can choose to upgrade those to `Possible` rather than drop, without
  breaking the existing fixture contract (which only asserts no `calls` edge
  for that site, not "edge absent everywhere").

---

## 2026-04-16: Self-call scope
**Category:** [PRINCIPLED]
**Decision:** `self.X()` inside a method resolves only to methods defined on
  the same class. No inherited-method resolution, no `super()`, no MRO walk.
**Considered:** walking the class hierarchy via `bases` to include inherited
  methods; resolving `super()` calls to the immediate parent's method.
**Why this:** 0.3.0 AST gives us the class body and its direct methods.
  Hierarchy walking requires resolving base classes to their defining modules,
  which works when bases are local but not when they're imported from
  third-party packages or use dynamic bases. The "partial answer" problem:
  resolving some inherited calls but not others is worse than uniform "drop".
**Rust implication:** do handle inheritance and MRO via a simple class-
  hierarchy resolver. `super()` resolves to parent's method by name. This
  is cheap with tree-sitter and closes a real gap. Not full type inference —
  just class-hierarchy walking.

---

## 2026-04-16: Call source granularity
**Category:** [PRINCIPLED]
**Decision:** Call edges originate from `py_function` and `py_method` source
  nodes only. Module-level top-level statements (e.g., `foo()` at module
  scope) are not call sources and produce no edges.
**Considered:** treating the module node itself as a call source for
  top-level statements; synthesising a module-init node.
**Why this:** 0.2.0 has no module-init node, and introducing one would
  require deciding id scheme, traversal semantics, and how it interacts with
  `defines` edges. Top-level calls in real Python code are mostly imports,
  constants, and module-setup code — not the handler-dispatch semantics the
  flow machinery cares about.
**Rust implication:** include top-level statements as call sources. Model
  them as originating from an implicit `Module.__init__` node or from the
  module node itself. Top-level code runs on import and its calls are real
  and discoverable — dropping them was a Python scope decision, not a
  correctness one.

---

## 2026-04-16: Import edge granularity
**Category:** [SCOPE-DRIVEN]
**Decision:** Import edges are module → module only. `from pkg.mod import X`
  emits an edge to `pkg.mod`, never to the symbol `X`. `from pkg import mod`
  emits an edge to `pkg.mod` (submodule) or falls back to `pkg` (`__init__`).
**Considered:** symbol-level import edges (`from x import y` → edge from
  current module to the `y` node inside `x`).
**Why this:** 0.2.0 emits module-level import edges. Changing this would
  shift the meaning of `imports` across the whole graph and every analyzer,
  but only Python can produce symbol-level edges (the other 12 analyzers are
  regex). Consistency wins for now.
**Rust implication:** emit symbol-level import edges. Tree-sitter gives you
  enough information to resolve both import forms to the specific bound
  symbol at no extra cost. This tightens impact analysis meaningfully —
  "who imports hash_password" is more actionable than "who imports helpers".

---

## 2026-04-16: Namespace packages (PEP 420)
**Category:** [SCOPE-DRIVEN]
**Decision:** `_find_packages` requires `__init__.py`. Directories without
  one are not treated as packages, even if they contain `.py` files.
**Considered:** relaxing the requirement to match PEP 420 behaviour.
**Why this:** `__init__.py` is a reliable marker in real Python codebases;
  PEP 420 namespace packages are rare in practice. Relaxing the rule would
  pick up arbitrary directories containing test fixtures, examples, and
  notebooks as "packages" in some repos. 0.2.0 had the same limitation.
**Rust implication:** fix it. "Is this a package" becomes "does this
  directory contain `.py` files" rather than "does it have `__init__.py`".
  Better for modern Python codebases using PEP 420 (notably src-layout with
  implicit namespace packages).

---

## 2026-04-16: Route extraction remains regex
**Category:** [SCOPE-DRIVEN]
**Decision:** `_ROUTE_DECORATOR` and `_DJANGO_PATH` regex patterns carry over
  from 0.2.0 verbatim. Routes are not extracted via AST in 0.3.0.
**Considered:** using `ast.walk()` to find decorator expressions on
  FunctionDef nodes, parsing `@app.get("/path")` structurally.
**Why this:** AST decorator extraction is a separate piece of work:
  APIRouter vs Flask Blueprint vs Django CBVs each have different AST
  shapes, prefix mounting, and dependency chains. 0.3.0's scope is the AST
  core (methods, calls, imports, flow-tighten). Route extraction is a
  downstream beneficiary but not a required deliverable.
**Rust implication:** route extraction is AST-native from day one.
  Tree-sitter parses decorators as child nodes of the function definition;
  pattern-matching against decorator AST is more reliable than regex.
  Handle APIRouter prefix mounting, Flask Blueprint, Django CBVs, FastAPI
  dependencies — each has a distinct AST shape.

---

## 2026-04-16: Nested functions not modelled
**Category:** [PRINCIPLED]
**Decision:** Nested `def` and `class` inside a function or method are not
  emitted as nodes. `find_calls` walks the outer body but skips nested
  function bodies (so calls INSIDE a nested function don't bubble up).
  Calls TO a nested function name from the enclosing scope drop silently
  (the nested name is not in `_qname_to_id`).
**Considered:** emitting `py_function` nodes for nested defs with
  parent-scoped IDs; or flattening nested calls into the enclosing function.
**Why this:** nested defs add ID scheme complexity (what's the qname for
  a closure?) and flow-explosion risk. In real Python, nested defs are
  relatively rare at the structural level that matters for navigation.
**Rust implication:** model nested functions explicitly with a parent
  relationship (`Function::Nested { parent: FunctionId }`). Closures could
  be a property on `Function` or their own node type. Nested functions
  do get called and their call sites are real — dropping them undercounts.

---

## 2026-04-16: _auto_flows prefer-calls rule
**Category:** [SCOPE-DRIVEN]
**Decision:** When a source node has any outgoing `calls` edge, the flow
  traversal from that source follows only `calls` and skips `defines`/
  `contains`/`imports`/`uses`. Sources without any outgoing `calls` fall
  back to the full `_FOLLOW_TYPES` set. Module and class sources (which
  don't emit `calls` in 0.3.0) unaffected — still follow `defines` because
  that's the only way to reach their children.
**Considered:** dropping `defines`/`contains` entirely from the follow set
  (breaks the "enter a module" path); weighting edges by traversal-depth
  priority; requiring the WHOLE walk to stop walking defines once any
  calls edge has been traversed.
**Why this:** surgical intervention that tightens flows without a full
  traversal-semantics redesign. The rule only bites at function/method
  sources — which in 0.3.0 Python means it's narrowly triggered, since
  those sources have `calls` edges but no `defines`/`contains` edges to
  skip. It's future-proofing more than a bug fix for the real 0.2.0
  pathology (module → many defines siblings, and `imports` → other module
  → many defines), which requires deeper work to address.
**Rust implication:** generalise. Define edge categories
  (structural: `defines`, `contains`; behavioural: `calls`, `uses`;
  metadata: `documents`, `tests`) and let traversal take a category
  priority ordering. Default ordering prefers behavioural over structural
  for function sources and structural over behavioural for module/class
  sources. 0.3.0's rule becomes a specific configuration of a more
  general system. Also: handled_by should point to the specific handler
  function (decorator-aware routing, item 8), which eliminates the
  module-defines pollution at the source.

---

## 2026-04-16: Parsing passes
**Category:** [SCOPE-DRIVEN]
**Decision:** Two-pass design. Pass 1 parses every file and emits structural
  nodes + populates `self._qname_to_id`. Pass 2 walks each file's AST again
  for imports + call resolution, using the completed symbol table from
  pass 1. ast.Modules are kept in memory between passes (cached in a list).
**Considered:** single-pass with deferred edge resolution, streaming walk.
**Why this:** stdlib `ast.parse` encourages separate concerns per walk.
  Resolving calls requires all symbols to be known, which requires pass 1
  to complete first. In-memory cost is bounded (one tree per source file).
**Rust implication:** single-walk with tree-sitter. Parse once, traverse
  once, accumulate nodes and edges via visitor pattern with multiple
  accumulators. Unresolved call sites get fixed up in a final resolution
  pass over the accumulated data — but parsing happens exactly once.
  Tree-sitter trees are compact and droppable after the walk.

---

## 2026-04-16: qname separator
**Category:** [SCOPE-DRIVEN]
**Decision:** internal qname strings use `.` as separator
  (`myapp.users.User.login`). Strings are internal-only, not serialised.
**Considered:** `::` or `/` as separator.
**Why this:** `.` matches Python's native import syntax. Simple and
  unambiguous for Python identifiers (no legal Python identifier contains
  a dot).
**Rust implication:** consider `::` for namespacing. Matches Rust's own
  conventions and avoids collision when a filename contains a dot (e.g.,
  `config.prod.py`). Minor choice — either works if the canonical separator
  is documented. The qname format is an internal contract; the fixture
  assertions don't constrain it.

---

## 2026-04-16: Syntax error handling
**Category:** [SCOPE-DRIVEN]
**Decision:** `parse_module` returns `None` on `SyntaxError`. The file is
  skipped entirely — no nodes or edges emitted from it, no diagnostic
  captured.
**Considered:** partial-parse with error markers, per-statement try/except
  during AST walk.
**Why this:** stdlib `ast.parse` is all-or-nothing. A single syntax error
  anywhere in a file raises before any AST is produced. Working around this
  requires either a tolerant parser (third-party dep) or pre-processing
  heuristics (fragile).
**Rust implication:** tree-sitter has first-class error recovery. A file
  with syntax errors produces a partial AST with ERROR nodes in the broken
  regions. Rust should propagate this: extract valid structural nodes,
  record error locations on the file's module node, continue. The graph
  gets the good parts plus provenance on what couldn't be parsed. Add a
  Rust fixture with a deliberate syntax error that asserts the partial
  graph is produced correctly.
