mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-08 03:08:13 +00:00
feat(python-scope): arity metadata + bind function decls in parent scope
Unit 2 of the python migration architectural plan
(docs/plans/2026-04-19-001-refactor-python-migration-architectural-plan.md).
Two changes that the registry-primary path needs before any of the
arity-sensitive failures can move:
1. Arity metadata on scope-extracted Function/Method defs.
- New helper `languages/python/arity-metadata.ts` reuses
`pythonMethodConfig.extractParameters` so self/cls stripping,
defaults, and *args/**kwargs detection match legacy semantics.
- `emit-captures.ts` synthesizes
`@declaration.parameter-count` /
`@declaration.required-parameter-count` /
`@declaration.parameter-types` captures on every
`@declaration.function` match.
- Generic `scope-extractor.ts buildDefFromDeclarationMatch` reads
the three optional captures into `SymbolDefinition`. Absence is
still the no-op default for non-Python providers.
2. Hoist function/class declaration bindings to the enclosing scope.
The "innermost scope containing the anchor" default placed
`def greet(...)` inside greet's OWN body — invisible to other
module-level callers, so every flag-on free-call resolved to
`unresolved`. The hoist condition (`anchor range == innermost
range`) only fires for scope-creating declarations, so variable /
for-loop captures whose anchor is a child identifier stay put.
Hooks can still override via `bindingScopeFor`.
Verification:
- Flag-off: 191/191 (identical baseline).
- Flag-on (REGISTRY_PRIMARY_PYTHON=1): 31 fail / 160 pass
(was 32/159; the hoist unblocks free-call resolution end-to-end).
- tsc --noEmit clean.
Per-(source,target) edge collapse for multi-call-site cases
(default-params, variadic) still pending — landing it without
regressing the static-method find_user fixture (which expects two
distinct edges through different targets) needs the ownership-aware
qualified-id work that lands with Unit 4 / Unit 11.
This commit is contained in:
parent
4013779a70
commit
206c18f836
3 changed files with 146 additions and 2 deletions
|
|
@ -0,0 +1,54 @@
|
|||
/**
|
||||
* Extract Python arity metadata from a `function_definition` tree-sitter
|
||||
* node — parameter count, required count, and (where present) a type
|
||||
* list that the existing `pythonArityCompatibility` hook reads.
|
||||
*
|
||||
* Mirrors the legacy `buildMethodProps` conversion so scope-extracted
|
||||
* defs carry the same arity semantics as the parse-worker path:
|
||||
* - `self` / `cls` are stripped (consumed by `extractPythonParameters`).
|
||||
* - Defaulted params contribute to `optionalCount`, flipping
|
||||
* `requiredParameterCount = total − optionalCount`.
|
||||
* - Variadic (`*args` / `**kwargs`) collapses `parameterCount` to
|
||||
* `undefined`, which `pythonArityCompatibility` then treats as
|
||||
* `'unknown'` — keeping the candidate in the registry's lookup set.
|
||||
* - `parameterTypes` is populated only with real type text, matching
|
||||
* legacy behavior.
|
||||
*/
|
||||
|
||||
import type { SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
import { pythonMethodConfig } from '../../method-extractors/configs/python.js';
|
||||
|
||||
export interface PythonArityMetadata {
|
||||
readonly parameterCount: number | undefined;
|
||||
readonly requiredParameterCount: number | undefined;
|
||||
readonly parameterTypes: readonly string[] | undefined;
|
||||
}
|
||||
|
||||
export function computePythonArityMetadata(fnNode: SyntaxNode): PythonArityMetadata {
|
||||
const params = pythonMethodConfig.extractParameters?.(fnNode) ?? [];
|
||||
|
||||
let hasVariadic = false;
|
||||
let optionalCount = 0;
|
||||
const types: string[] = [];
|
||||
for (const p of params) {
|
||||
if (p.isVariadic) hasVariadic = true;
|
||||
else if (p.isOptional) optionalCount++;
|
||||
if (p.type !== null) types.push(p.type);
|
||||
}
|
||||
|
||||
const total = params.length;
|
||||
const parameterCount = hasVariadic ? undefined : total;
|
||||
// Unlike legacy `buildMethodProps`, we populate `requiredParameterCount`
|
||||
// whenever the function isn't variadic — even when it equals
|
||||
// `parameterCount`. The scope-resolution registry needs a concrete min
|
||||
// to rule out under-application (e.g. picking `write_audit(x, y)` for
|
||||
// a 1-arg call). Legacy could get away with leaving it undefined
|
||||
// because its call-graph builder had a separate arity pre-filter.
|
||||
const requiredParameterCount = hasVariadic ? undefined : total - optionalCount;
|
||||
|
||||
return {
|
||||
parameterCount,
|
||||
requiredParameterCount,
|
||||
parameterTypes: types.length > 0 ? types : undefined,
|
||||
};
|
||||
}
|
||||
|
|
@ -17,10 +17,11 @@
|
|||
*/
|
||||
|
||||
import type { Capture, CaptureMatch } from 'gitnexus-shared';
|
||||
import { findNodeAtRange, nodeToCapture } from './ast-utils.js';
|
||||
import { findNodeAtRange, nodeToCapture, syntheticCapture } from './ast-utils.js';
|
||||
import { splitImportStatement } from './import-decomposer.js';
|
||||
import { getPythonParser, getPythonScopeQuery } from './query.js';
|
||||
import { synthesizeReceiverTypeBinding } from './receiver-binding.js';
|
||||
import { computePythonArityMetadata } from './arity-metadata.js';
|
||||
|
||||
export function emitPythonScopeCaptures(
|
||||
sourceText: string,
|
||||
|
|
@ -73,6 +74,44 @@ export function emitPythonScopeCaptures(
|
|||
continue;
|
||||
}
|
||||
|
||||
if (grouped['@declaration.function'] !== undefined) {
|
||||
// Synthesize arity captures on the declaration match so the
|
||||
// central scope-extractor picks them up alongside @declaration.name.
|
||||
// The anchor range is the function_definition itself — we resolve
|
||||
// the node and pipe it through the arity helper.
|
||||
const anchorCap = grouped['@declaration.function']!;
|
||||
const fnNode = findNodeAtRange(tree.rootNode, anchorCap.range, 'function_definition');
|
||||
if (fnNode !== null) {
|
||||
const arity = computePythonArityMetadata(fnNode);
|
||||
if (arity.parameterCount !== undefined) {
|
||||
grouped['@declaration.parameter-count'] = syntheticCapture(
|
||||
'@declaration.parameter-count',
|
||||
fnNode,
|
||||
String(arity.parameterCount),
|
||||
);
|
||||
}
|
||||
if (arity.requiredParameterCount !== undefined) {
|
||||
grouped['@declaration.required-parameter-count'] = syntheticCapture(
|
||||
'@declaration.required-parameter-count',
|
||||
fnNode,
|
||||
String(arity.requiredParameterCount),
|
||||
);
|
||||
}
|
||||
if (arity.parameterTypes !== undefined) {
|
||||
// Serialize as JSON so the consumer can round-trip without
|
||||
// inventing a quoting convention for type names that may
|
||||
// contain commas (`Dict[str, int]`).
|
||||
grouped['@declaration.parameter-types'] = syntheticCapture(
|
||||
'@declaration.parameter-types',
|
||||
fnNode,
|
||||
JSON.stringify(arity.parameterTypes),
|
||||
);
|
||||
}
|
||||
}
|
||||
out.push(grouped);
|
||||
continue;
|
||||
}
|
||||
|
||||
out.push(grouped);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -465,8 +465,20 @@ function pass2AttachDeclarations(
|
|||
// populated during Pass 2: those fields are written across passes,
|
||||
// so reading them mid-extraction yields a partial view. The
|
||||
// `scopeTree` argument is similarly snapshot-before-mutation.
|
||||
//
|
||||
// Auto-hoist for scope-creating declarations: when the declaration's
|
||||
// anchor range is the same node that produced `innermost` (e.g. a
|
||||
// `function_definition` is both `@scope.function` and the
|
||||
// `@declaration.function` anchor), the name is visible OUTSIDE the
|
||||
// body, not inside. Hoisting to the parent scope is what every
|
||||
// mainstream language wants for function/class declarations. Hooks
|
||||
// can override by returning a non-null scope id.
|
||||
const autoHostedId =
|
||||
innermost.parent !== null && rangesEqual(anchor.range, innermost.range)
|
||||
? innermost.parent
|
||||
: innermost.id;
|
||||
const bindingScopeId =
|
||||
provider.bindingScopeFor?.(match, draftToScope(innermost), scopeTree) ?? innermost.id;
|
||||
provider.bindingScopeFor?.(match, draftToScope(innermost), scopeTree) ?? autoHostedId;
|
||||
const bindingHost = draftById.get(bindingScopeId) ?? innermost;
|
||||
|
||||
const nameKey = deriveDeclarationName(match, def);
|
||||
|
|
@ -495,14 +507,44 @@ function buildDefFromDeclarationMatch(
|
|||
const qualifiedCap = match['@declaration.qualified_name'];
|
||||
const qualifiedName = qualifiedCap?.text;
|
||||
|
||||
// Optional arity metadata — producers (e.g. Python emit-captures)
|
||||
// synthesize these on function/method declarations. Their absence is
|
||||
// the normal case for other producers; readers treat undefined as
|
||||
// "unknown" per `SymbolDefinition` contract.
|
||||
const parameterCount = parseIntCapture(match['@declaration.parameter-count']);
|
||||
const requiredParameterCount = parseIntCapture(match['@declaration.required-parameter-count']);
|
||||
const parameterTypes = parseJsonStringArrayCapture(match['@declaration.parameter-types']);
|
||||
|
||||
return {
|
||||
nodeId: makeDefId(filePath, anchor.range, type, nameCap.text),
|
||||
filePath,
|
||||
type,
|
||||
...(qualifiedName !== undefined ? { qualifiedName } : { qualifiedName: nameCap.text }),
|
||||
...(parameterCount !== undefined ? { parameterCount } : {}),
|
||||
...(requiredParameterCount !== undefined ? { requiredParameterCount } : {}),
|
||||
...(parameterTypes !== undefined ? { parameterTypes } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function parseIntCapture(cap: { readonly text: string } | undefined): number | undefined {
|
||||
if (cap === undefined) return undefined;
|
||||
const n = Number.parseInt(cap.text, 10);
|
||||
return Number.isFinite(n) ? n : undefined;
|
||||
}
|
||||
|
||||
function parseJsonStringArrayCapture(
|
||||
cap: { readonly text: string } | undefined,
|
||||
): string[] | undefined {
|
||||
if (cap === undefined) return undefined;
|
||||
try {
|
||||
const parsed = JSON.parse(cap.text) as unknown;
|
||||
if (!Array.isArray(parsed)) return undefined;
|
||||
return parsed.every((x): x is string => typeof x === 'string') ? parsed : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function deriveDeclarationName(match: CaptureMatch, def: SymbolDefinition): string | undefined {
|
||||
const nameCap =
|
||||
match['@declaration.name'] ??
|
||||
|
|
@ -842,6 +884,15 @@ function extractArity(match: CaptureMatch): number | undefined {
|
|||
|
||||
// ─── Internal: range + capture utilities ───────────────────────────────────
|
||||
|
||||
function rangesEqual(a: Range, b: Range): boolean {
|
||||
return (
|
||||
a.startLine === b.startLine &&
|
||||
a.startCol === b.startCol &&
|
||||
a.endLine === b.endLine &&
|
||||
a.endCol === b.endCol
|
||||
);
|
||||
}
|
||||
|
||||
function rangeStrictlyContains(outer: Range, inner: Range): boolean {
|
||||
if (
|
||||
outer.startLine === inner.startLine &&
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue