Merge branch 'main' into dependabot/npm_and_yarn/gitnexus-web/zod-4.3.6

This commit is contained in:
Gergő Magyar 2026-05-12 23:43:57 +01:00 committed by GitHub
commit eb8a156250
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
194 changed files with 12903 additions and 590 deletions

View file

@ -158,6 +158,8 @@ jobs:
github_token: ${{ secrets.GITHUB_TOKEN }}
allowed_non_write_users: '*'
show_full_output: true
# Review posts use Bash (`gh`, etc.); default mode asks for approval — impossible in CI.
claude_args: '--dangerously-skip-permissions'
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
plugins: 'code-review@claude-code-plugins'
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ steps.pr.outputs.number }}'
prompt: '/code-review:code-review https://github.com/${{ github.repository }}/pull/${{ steps.pr.outputs.number }} --comment'

View file

@ -149,11 +149,16 @@ Indexed as **GitNexus** (4325 symbols, 10556 relationships, 300 execution flows)
## Keeping the Index Fresh
```bash
npx gitnexus analyze # basic refresh; preserves any existing embeddings
npx gitnexus analyze # incremental by default; preserves embeddings
npx gitnexus analyze --force # full rebuild from scratch (opt out of incremental)
npx gitnexus analyze --embeddings # also generate embeddings for new/changed nodes
npx gitnexus analyze --drop-embeddings # explicit opt-in to wipe existing embeddings
```
`analyze` runs **incrementally by default**. The pipeline still parses every file every run (cross-file resolution requires it), but tree-sitter parsing is **served from a content-addressed cache** at `.gitnexus/parse-cache.json` for chunks whose file contents haven't changed since the last run. Only changed-file rows (and their importers) are rewritten in LadybugDB; unchanged-file rows are preserved. Output is byte-equivalent to a full rebuild. Pass `--force` to wipe and re-index from scratch (e.g., to recover from a corrupt index, or after upgrading GitNexus).
The parse cache key is **content-addressed and version-tagged**: it survives `--force` runs, and is automatically invalidated by a `gitnexus` package upgrade (so a new tree-sitter grammar doesn't silently replay stale parse output). Safe to delete `.gitnexus/parse-cache.json` at any time — it'll be rebuilt on the next analyze.
Check `.gitnexus/meta.json` `stats.embeddings` (0 = none). A plain `analyze` no longer drops existing vectors — pass `--drop-embeddings` to wipe.
> Claude Code: PostToolUse hook detects a stale index after `git commit` and `git merge` and prompts the agent to run `analyze`. The hook does not invoke `analyze` itself.

View file

@ -55,6 +55,7 @@ RUN mkdir -p /data/gitnexus && chown -R node:node /data
COPY --from=builder --chown=node:node /app/gitnexus/dist ./gitnexus/dist
COPY --from=builder --chown=node:node /app/gitnexus/node_modules ./gitnexus/node_modules
COPY --from=builder --chown=node:node /app/gitnexus/package.json ./gitnexus/package.json
COPY --from=builder --chown=node:node /app/gitnexus/scripts/install-duckdb-extension.mjs ./gitnexus/scripts/install-duckdb-extension.mjs
COPY --from=builder --chown=node:node /app/gitnexus/vendor ./gitnexus/vendor
USER node

View file

@ -30,9 +30,15 @@ Format: **Trigger → Instruction → Reason**. Append new Signs when the same m
### Stale graph after edits
- **Trigger:** MCP warns index is behind `HEAD`, or search doesn't match latest commit.
- **Do:** `npx gitnexus analyze` (plus `--embeddings` if used).
- **Do:** `npx gitnexus analyze` (plus `--embeddings` if used). Runs incrementally by default — the pipeline parses every file every run (cross-file resolution requires it), but tree-sitter dispatch is skipped for unchanged file chunks via the content-addressed cache, and only changed-file rows (plus their importers, transitively) are rewritten in LadybugDB.
- **Why:** Tools query LadybugDB from last analyze; git changes are invisible until re-indexed.
### Index seems corrupt or "incremental" is misbehaving
- **Trigger:** `analyze` produces unexpected results, or `meta.json.incrementalInProgress` is set, or the index is in a half-state after a crash.
- **Do:** `npx gitnexus analyze --force` to rebuild from scratch. The dirty-flag check forces this automatically when a previous incremental run didn't complete cleanly, but `--force` is the manual escape hatch. Safe to delete `.gitnexus/parse-cache.json` at any time — content-addressed, will be regenerated.
- **Why:** Incremental writeback is selective DB row replacement; if the on-disk state is inconsistent for any reason, a full rebuild is the cheapest path back to a known-good index.
### Embeddings vanished after analyze
- **Trigger:** Semantic search quality drops; `stats.embeddings` in `meta.json` is 0 after refresh.

View file

@ -0,0 +1,95 @@
/**
* Custom ESLint rule: require `parseSourceSafe(parser, content, ...)` instead
* of direct `<parser>.parse(<content>, ...)` calls.
*
* Background: tree-sitter's Node.js native binding crashes with SIGSEGV on
* Windows when handed a JS string longer than 32 767 chars. The crash happens
* inside the binding's V8 string-to-buffer conversion and cannot be intercepted
* by JavaScript `try/catch`. `parseSourceSafe` (in
* `gitnexus/src/core/tree-sitter/safe-parse.ts`) routes large inputs through
* the chunked-callback overload of `parser.parse(input, ...)` which bypasses
* the broken conversion path. PR #1433 fixed every direct call site at the
* time; this rule prevents new direct calls from creeping in.
*
* The rule is auto-fixable for the call-site rewrite. It does NOT auto-add the
* import (computing the correct relative path per file is brittle); after the
* call rewrite runs, the consumer file's `tsc` will complain about an
* undefined identifier and the developer adds the import. This is the same
* tradeoff `unused-imports/no-unused-imports` makes in the opposite direction.
*
* False-positive suppression:
* - Skips calls whose receiver is a known non-tree-sitter library (`JSON`,
* `URL`, `marked`, `Number`).
* - Skips calls whose first argument is a string-literal (grammar-load smoke
* tests like `_testParser.parse('service X { rpc Y (R) returns (R); }')`).
* - Skips test files (`.test.ts`/`.test.tsx`/`.spec.ts`).
* - Skips the `safe-parse.ts` helper itself.
*/
const SKIPPED_RECEIVERS = new Set(['JSON', 'URL', 'marked', 'Number', 'Math']);
export default {
meta: {
type: 'problem',
docs: {
description:
'Require parseSourceSafe instead of direct tree-sitter `<parser>.parse(content, ...)` calls (Windows SIGSEGV protection)',
recommended: true,
},
fixable: 'code',
schema: [],
messages: {
useSafeParse:
'Direct `{{receiver}}.parse(...)` can SIGSEGV on Windows for inputs > 32 767 chars (uncatchable from JS). Use `parseSourceSafe({{receiver}}, ...)` from `core/tree-sitter/safe-parse.js`. Auto-fix rewrites the call; add the missing import yourself.',
},
},
create(context) {
const filename = context.filename ?? context.getFilename();
// Don't lint the helper itself or test files.
if (filename.includes('safe-parse')) return {};
if (/[.](?:test|spec)\.tsx?$/.test(filename)) return {};
const sourceCode = context.sourceCode ?? context.getSourceCode();
return {
CallExpression(node) {
const callee = node.callee;
if (callee.type !== 'MemberExpression') return;
if (callee.computed) return;
if (callee.property.type !== 'Identifier') return;
if (callee.property.name !== 'parse') return;
// Skip known non-tree-sitter receivers.
if (callee.object.type === 'Identifier' && SKIPPED_RECEIVERS.has(callee.object.name)) {
return;
}
// Smoke tests pass a string literal directly; those are trivially safe.
const firstArg = node.arguments[0];
if (!firstArg) return;
if (firstArg.type === 'Literal' && typeof firstArg.value === 'string') return;
if (firstArg.type === 'TemplateLiteral' && firstArg.expressions.length === 0) return;
const receiverText = sourceCode.getText(callee.object);
// Receiver-text-shape skip: anything matching well-known JS APIs that
// happen to have a `.parse(<expr>)` shape but aren't tree-sitter.
if (
/^(JSON|URL|marked|Number|Math|Date|globalThis\.JSON)\b/.test(receiverText) ||
/\bjson\.parse\b/i.test(receiverText)
) {
return;
}
context.report({
node,
messageId: 'useSafeParse',
data: { receiver: receiverText },
fix(fixer) {
const argsText = node.arguments.map((arg) => sourceCode.getText(arg)).join(', ');
return fixer.replaceText(node, `parseSourceSafe(${receiverText}, ${argsText})`);
},
});
},
};
},
};

View file

@ -3,6 +3,15 @@ import tsParser from '@typescript-eslint/parser';
import unusedImports from 'eslint-plugin-unused-imports';
import reactHooks from 'eslint-plugin-react-hooks';
import prettierConfig from 'eslint-config-prettier';
import requireSafeParse from './eslint-rules/require-safe-parse.mjs';
// Local plugin hosting custom rules that enforce GitNexus-specific invariants
// (currently: the Windows-SIGSEGV-safe parser entrypoint).
const gitnexusLocalPlugin = {
rules: {
'require-safe-parse': requireSafeParse,
},
};
// Selectors that protect MCP-reachable code from corrupting the JSON-RPC
// stdio frame stream. The MCP-reachable block below uses these directly;
@ -135,6 +144,23 @@ export default [
},
},
// Windows SIGSEGV protection: every tree-sitter parse in `core/` must route
// through parseSourceSafe. Direct `<parser>.parse(content, ...)` crashes on
// Windows for inputs > 32 767 chars (V8 string-conversion bug, uncatchable
// from JS). The rule auto-fixes the call site; the developer adds the
// missing import after the fix runs. Out of scope: tests (skipped by the
// rule), the helper itself (`safe-parse.ts`), and the `grpc-patterns/proto.ts`
// grammar-load smoke test (filtered by string-literal-arg skip in the rule).
{
files: ['gitnexus/src/core/**/*.ts'],
plugins: {
gitnexus: gitnexusLocalPlugin,
},
rules: {
'gitnexus/require-safe-parse': 'error',
},
},
// React-specific rules for gitnexus-web
{
files: ['gitnexus-web/src/**/*.{ts,tsx}'],

View file

@ -40,11 +40,27 @@ export interface MethodDispatchIndex {
readonly mroByOwnerDefId: ReadonlyMap<DefId, readonly DefId[]>;
/** Interfaces / traits → classes that implement them. */
readonly implsByInterfaceDefId: ReadonlyMap<DefId, readonly DefId[]>;
/**
* Optional parallel MRO view that EXCLUDES mixin-like augmentation
* (e.g., PHP traits). Populated only when the input supplies
* `computeExtendsOnlyMro`. Used by the super-branch dispatch in
* `receiver-bound-calls` so that `parent::method()` walks the
* inheritance chain only, not the trait-augmented one. Undefined for
* languages without mixin-like semantics callers should fall back
* to `mroFor` when this is missing.
*/
readonly extendsOnlyMroByOwnerDefId?: ReadonlyMap<DefId, readonly DefId[]>;
/** `mroByOwnerDefId.get`, with an empty frozen array on miss. */
mroFor(ownerDefId: DefId): readonly DefId[];
/** `implsByInterfaceDefId.get`, with an empty frozen array on miss. */
implementorsOf(interfaceDefId: DefId): readonly DefId[];
/**
* `extendsOnlyMroByOwnerDefId.get`, with an empty frozen array on miss.
* Undefined when `extendsOnlyMroByOwnerDefId` was not populated; callers
* should treat this as equivalent to `mroFor` for non-mixin languages.
*/
readonly extendsOnlyMroFor?: (ownerDefId: DefId) => readonly DefId[];
}
export interface MethodDispatchInput {
@ -81,12 +97,25 @@ export interface MethodDispatchInput {
* write-wins policy and fires at most once per unique owner.
*/
readonly implementsOf: (ownerDefId: DefId) => readonly DefId[];
/**
* Optional: return the EXTENDS-only ancestor chain for `ownerDefId`,
* excluding the owner itself AND any mixin-like augmentation (e.g.,
* PHP traits). Languages without mixin semantics leave this undefined
* and the index's `extendsOnlyMroByOwnerDefId` stays unpopulated.
*
* Same contract as `computeMro`: pure, deterministic, `[]` on no parents.
* Called at most once per unique owner (first-write-wins).
*/
readonly computeExtendsOnlyMro?: (ownerDefId: DefId) => readonly DefId[];
}
// ─── Builder ────────────────────────────────────────────────────────────────
export function buildMethodDispatchIndex(input: MethodDispatchInput): MethodDispatchIndex {
const mroByOwnerDefId = new Map<DefId, readonly DefId[]>();
const extendsOnlyByOwnerDefId = input.computeExtendsOnlyMro
? new Map<DefId, readonly DefId[]>()
: undefined;
const implsBuilding = new Map<DefId, DefId[]>();
const implsSeen = new Map<DefId, Set<DefId>>();
@ -97,6 +126,14 @@ export function buildMethodDispatchIndex(input: MethodDispatchInput): MethodDisp
const chain = input.computeMro(ownerId);
mroByOwnerDefId.set(ownerId, Object.freeze(chain.slice()));
}
if (
input.computeExtendsOnlyMro !== undefined &&
extendsOnlyByOwnerDefId !== undefined &&
!extendsOnlyByOwnerDefId.has(ownerId)
) {
const extOnly = input.computeExtendsOnlyMro(ownerId);
extendsOnlyByOwnerDefId.set(ownerId, Object.freeze(extOnly.slice()));
}
for (const ifaceId of input.implementsOf(ownerId)) {
let seen = implsSeen.get(ifaceId);
@ -121,7 +158,7 @@ export function buildMethodDispatchIndex(input: MethodDispatchInput): MethodDisp
implsByInterfaceDefId.set(ifaceId, Object.freeze(owners.slice()));
}
return wrapIndex(mroByOwnerDefId, implsByInterfaceDefId);
return wrapIndex(mroByOwnerDefId, implsByInterfaceDefId, extendsOnlyByOwnerDefId);
}
// ─── Internal ───────────────────────────────────────────────────────────────
@ -131,8 +168,9 @@ const EMPTY: readonly DefId[] = Object.freeze([]);
function wrapIndex(
mroByOwnerDefId: Map<DefId, readonly DefId[]>,
implsByInterfaceDefId: Map<DefId, readonly DefId[]>,
extendsOnlyMroByOwnerDefId: Map<DefId, readonly DefId[]> | undefined,
): MethodDispatchIndex {
return {
const base: MethodDispatchIndex = {
mroByOwnerDefId,
implsByInterfaceDefId,
mroFor(ownerDefId: DefId): readonly DefId[] {
@ -142,4 +180,14 @@ function wrapIndex(
return implsByInterfaceDefId.get(interfaceDefId) ?? EMPTY;
},
};
if (extendsOnlyMroByOwnerDefId !== undefined) {
return {
...base,
extendsOnlyMroByOwnerDefId,
extendsOnlyMroFor(ownerDefId: DefId): readonly DefId[] {
return extendsOnlyMroByOwnerDefId.get(ownerDefId) ?? EMPTY;
},
};
}
return base;
}

View file

@ -423,13 +423,30 @@ function applyArityFilter(
}
let anyCompatible = false;
let anyUnknown = false;
for (const state of perCandidate.values()) {
const verdict = arityFn(callsite, state.def);
state.signals.arityVerdict = verdict;
if (verdict === 'compatible') anyCompatible = true;
else if (verdict === 'unknown') anyUnknown = true;
}
if (!anyCompatible) return;
// When ALL candidates are 'incompatible' (none compatible, none unknown),
// the call is genuinely arity-broken — drop every candidate so the
// registry returns no resolution. This matches the PHP variadic case
// f(int $req, ...$rest) called with zero args: every candidate definitively
// rejects, and emitting an edge to a definitively-rejected callable is
// a false positive. When some candidates are 'unknown' (missing metadata),
// keep the set so downstream evidence can break the tie — that's the
// original safety-fallback behavior.
if (!anyCompatible) {
if (!anyUnknown) {
for (const defId of perCandidate.keys()) {
perCandidate.delete(defId);
}
}
return;
}
// Filter: when at least one compatible candidate exists, drop incompatibles.
for (const [defId, state] of perCandidate) {

View file

@ -63,7 +63,7 @@
"vitest": "^4.0.18"
},
"engines": {
"node": ">=20.0.0"
"node": ">=22.0.0"
},
"optionalDependencies": {
"node-addon-api": "^8.0.0",
@ -1646,9 +1646,9 @@
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/utf8": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz",
"integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz",
"integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==",
"license": "BSD-3-Clause"
},
"node_modules/@rolldown/binding-android-arm64": {

View file

@ -28,6 +28,7 @@ interface RepoStats {
export interface AIContextOptions {
skipAgentsMd?: boolean;
noStats?: boolean;
skipSkills?: boolean;
}
const GITNEXUS_START_MARKER = '<!-- gitnexus:start -->';
@ -94,6 +95,7 @@ function generateGitNexusContent(
generatedSkills?: GeneratedSkillInfo[],
groupNames?: string[],
noStats?: boolean,
skipSkills?: boolean,
): string {
const generatedRows =
generatedSkills && generatedSkills.length > 0
@ -105,14 +107,26 @@ function generateGitNexusContent(
.join('\n')
: '';
const skillsTable = `| Task | Read this skill file |
|------|---------------------|
| Understand architecture / "How does X work?" | \`.claude/skills/gitnexus/gitnexus-exploring/SKILL.md\` |
// Standard skill rows reference files installed by installSkills(). When
// --skip-skills suppresses that install, these rows must be omitted — else
// AGENTS.md/CLAUDE.md would direct agents to read files that don't exist.
// Community skills (generatedRows) live in .claude/skills/generated/ and
// are independent of --skip-skills, so they remain when present.
const standardSkillsRows = skipSkills
? ''
: `| Understand architecture / "How does X work?" | \`.claude/skills/gitnexus/gitnexus-exploring/SKILL.md\` |
| Blast radius / "What breaks if I change X?" | \`.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md\` |
| Trace bugs / "Why is X failing?" | \`.claude/skills/gitnexus/gitnexus-debugging/SKILL.md\` |
| Rename / extract / split / refactor | \`.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md\` |
| Tools, resources, schema reference | \`.claude/skills/gitnexus/gitnexus-guide/SKILL.md\` |
| Index, status, clean, wiki CLI commands | \`.claude/skills/gitnexus/gitnexus-cli/SKILL.md\` |${generatedRows ? '\n' + generatedRows : ''}`;
| Index, status, clean, wiki CLI commands | \`.claude/skills/gitnexus/gitnexus-cli/SKILL.md\` |`;
const tableBody = [standardSkillsRows, generatedRows].filter(Boolean).join('\n');
const skillsTable = tableBody
? `| Task | Read this skill file |
|------|---------------------|
${tableBody}`
: '';
return `${GITNEXUS_START_MARKER}
# GitNexus Code Intelligence
@ -153,11 +167,15 @@ This repository is listed under GitNexus **group(s): ${groupNames.join(', ')}**
`
: ''
}## CLI
}${
skillsTable
? `## CLI
${skillsTable}
${GITNEXUS_END_MARKER}`;
`
: ''
}${GITNEXUS_END_MARKER}`;
}
/**
@ -319,6 +337,7 @@ export async function generateAIContextFiles(
generatedSkills,
groupNames,
options?.noStats,
options?.skipSkills,
);
const createdFiles: string[] = [];
@ -337,10 +356,14 @@ export async function generateAIContextFiles(
createdFiles.push('CLAUDE.md (skipped via --skip-agents-md)');
}
// Install skills to .claude/skills/gitnexus/
const installedSkills = await installSkills(repoPath);
if (installedSkills.length > 0) {
createdFiles.push(`.claude/skills/gitnexus/ (${installedSkills.length} skills)`);
// Install skills to .claude/skills/gitnexus/ (unless --skip-skills)
if (!options?.skipSkills) {
const installedSkills = await installSkills(repoPath);
if (installedSkills.length > 0) {
createdFiles.push(`.claude/skills/gitnexus/ (${installedSkills.length} skills)`);
}
} else {
createdFiles.push('.claude/skills/gitnexus/ (skipped via --skip-skills)');
}
return { files: createdFiles };

View file

@ -119,6 +119,10 @@ export interface AnalyzeOptions {
skipAgentsMd?: boolean;
/** Omit volatile symbol/relationship counts from AGENTS.md and CLAUDE.md. */
noStats?: boolean;
/** Skip installing standard GitNexus skill files to .claude/skills/gitnexus/. */
skipSkills?: boolean;
/** Pure index mode: skip all file injection (AGENTS.md, CLAUDE.md, skills). */
indexOnly?: boolean;
/** Index the folder even when no .git directory is present. */
skipGit?: boolean;
/**
@ -150,6 +154,24 @@ export interface AnalyzeOptions {
embeddingDevice?: string;
}
/**
* Whether the post-index skill step should run.
*
* The gated block does two things in sequence: (1) generates the community
* skill files from `--skills`, and (2) re-runs `generateAIContextFiles` so
* AGENTS.md/CLAUDE.md can reference the freshly written skills. Both are
* suppressed together `--index-only` drops the entire step, not just the
* community-skill write. Name retained for the test contract; see call site
* in `analyzeCommand` for the AGENTS.md/CLAUDE.md re-generation it also gates.
*
* Kept as a pure helper so the `--index-only --skills` contract is unit-tested
* without booting the full analyze pipeline (#742 review).
*/
export const shouldGenerateCommunitySkillFiles = (
options: Pick<AnalyzeOptions, 'skills' | 'indexOnly'> | undefined,
pipelineResult: unknown,
): boolean => Boolean(options?.skills && pipelineResult && !options?.indexOnly);
export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOptions) => {
if (ensureHeap()) return;
@ -245,6 +267,18 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
console.log('\n GitNexus Analyzer\n');
// `--index-only` is the stronger contract — it suppresses every form of file
// injection, including community skill writes that `--skills` would normally
// produce. Surface the override explicitly so users don't wonder why a
// pipeline re-index ran but no skill files appeared. The pipeline still
// re-runs (see `force: options?.force || options?.skills` below); the warning
// is purely about the dropped post-index write step.
if (options?.indexOnly && options?.skills) {
console.log(
' Note: --index-only overrides --skills; community skill files will not be written.\n',
);
}
let repoPath: string;
if (inputPath) {
repoPath = path.resolve(inputPath);
@ -399,6 +433,9 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
// ── Run shared analysis orchestrator ───────────────────────────────
try {
const skipAll = options?.indexOnly;
const skipAgentsMd = skipAll || options?.skipAgentsMd;
const skipSkills = skipAll || options?.skipSkills;
const result = await runFullAnalysis(
repoPath,
{
@ -410,7 +447,8 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
embeddingsNodeLimit,
dropEmbeddings: options?.dropEmbeddings,
skipGit: options?.skipGit,
skipAgentsMd: options?.skipAgentsMd,
skipAgentsMd,
skipSkills,
noStats: options?.noStats,
registryName: options?.name,
// Registry-collision bypass — its own CLI flag, intentionally NOT
@ -456,8 +494,10 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
// a healthy index.
await assertAnalysisFinalized(repoPath);
// Skill generation (CLI-only, uses pipeline result from analysis)
if (options?.skills && result.pipelineResult) {
// Skill generation (CLI-only, uses pipeline result from analysis).
// Gated so `--index-only --skills` skips community skill writes too
// (`shouldGenerateCommunitySkillFiles` — see unit test).
if (shouldGenerateCommunitySkillFiles(options, result.pipelineResult)) {
updateBar(99, 'Generating skill files...');
try {
const { generateSkillFiles } = await import('./skill-gen.js');
@ -497,7 +537,7 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
processes: s.processes,
},
skillResult.skills,
{ skipAgentsMd: options?.skipAgentsMd, noStats: options?.noStats },
{ skipAgentsMd, skipSkills, noStats: options?.noStats },
);
}
} catch {

View file

@ -33,9 +33,20 @@ program
'Drop existing embeddings on rebuild. By default, an `analyze` without `--embeddings` ' +
'preserves any embeddings already present in the index.',
)
.option('--skills', 'Generate repo-specific skill files from detected communities')
.option(
'--skills',
'Generate repo-specific skill files from detected communities ' +
'(no-op when --index-only is also set).',
)
.option('--skip-agents-md', 'Skip updating the gitnexus section in AGENTS.md and CLAUDE.md')
.option('--no-stats', 'Omit volatile file/symbol counts from AGENTS.md and CLAUDE.md')
.option(
'--skip-skills',
'Skip installing standard GitNexus skill files under .claude/skills/gitnexus/. ' +
'Does not suppress community skills from --skills (those use .claude/skills/generated/). ' +
'Use --index-only to skip all AI-context file injection.',
)
.option('--index-only', 'Pure index mode: skip all file injection (AGENTS.md, CLAUDE.md, skills)')
.option(
'--skip-git',
'Treat the provided path/cwd as the index root and skip parent git-root discovery',

View file

@ -86,6 +86,9 @@ async function findRepoForCwd(cwd: string): Promise<{
export async function augment(pattern: string, cwd?: string): Promise<string> {
if (!pattern || pattern.length < 3) return '';
const patternFirstWord = pattern.trim().replace(/'/g, "''").split(/\s+/)[0];
if (!patternFirstWord || patternFirstWord.length < 2) return '';
const workDir = cwd || process.cwd();
try {
@ -104,9 +107,7 @@ export async function augment(pattern: string, cwd?: string): Promise<string> {
}
// Step 1: BM25 search (fast, no embeddings)
const { results: bm25Results } = await searchFTSFromLbug(pattern, 10, repoId);
if (bm25Results.length === 0) return '';
const { results: bm25Results, ftsAvailable } = await searchFTSFromLbug(pattern, 10, repoId);
// Step 2: Map BM25 file results to symbols
const symbolMatches: Array<{
@ -124,7 +125,7 @@ export async function augment(pattern: string, cwd?: string): Promise<string> {
repoId,
`
MATCH (n) WHERE n.filePath = '${escaped}'
AND n.name CONTAINS '${pattern.replace(/'/g, "''").split(/\s+/)[0]}'
AND n.name CONTAINS '${patternFirstWord}'
RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath
LIMIT 3
`,
@ -143,6 +144,29 @@ export async function augment(pattern: string, cwd?: string): Promise<string> {
}
}
// When FTS indexes are unavailable (read-only DB, first run before indexes are built),
// fall back to a direct name CONTAINS query so enrichment still works.
if (symbolMatches.length === 0 && !ftsAvailable) {
const fallbackRows = await executeQuery(
repoId,
`
MATCH (n)
WHERE n.name CONTAINS '${patternFirstWord}'
RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath
LIMIT 5
`,
).catch(() => []);
for (const sym of fallbackRows) {
symbolMatches.push({
nodeId: sym.id || sym[0],
name: sym.name || sym[1],
type: sym.type || sym[2],
filePath: sym.filePath || sym[3],
score: 1.0,
});
}
}
if (symbolMatches.length === 0) return '';
// Step 3: Batch-fetch callers/callees/processes/cohesion for top matches

View file

@ -10,6 +10,7 @@ import {
isLanguageAvailable,
resolveLanguageKey,
} from '../tree-sitter/parser-loader.js';
import { parseSourceSafe } from '../tree-sitter/safe-parse.js';
const parserCache = new Map<string, any>();
@ -29,7 +30,7 @@ export const ensureAndParse = async (content: string, filePath: string): Promise
parserCache.set(parserKey, parserInstance);
}
return parserInstance.parse(content);
return parseSourceSafe(parserInstance, content);
};
const FUNCTION_LIKE_TYPES = new Set([

View file

@ -40,8 +40,11 @@ const readConfig = (): HttpConfig | null => {
const rawDims = process.env.GITNEXUS_EMBEDDING_DIMS;
let dimensions: number | undefined;
if (rawDims !== undefined) {
if (!/^\d+$/.test(rawDims)) {
throw new Error(`GITNEXUS_EMBEDDING_DIMS must be a positive integer, got "${rawDims}"`);
}
const parsed = parseInt(rawDims, 10);
if (Number.isNaN(parsed) || parsed <= 0) {
if (parsed <= 0) {
throw new Error(`GITNEXUS_EMBEDDING_DIMS must be a positive integer, got "${rawDims}"`);
}
dimensions = parsed;
@ -91,7 +94,13 @@ interface EmbeddingItem {
* @param model - Model name for the request body
* @param apiKey - Bearer token (only used in Authorization header)
* @param batchIndex - Logical batch number (for error context)
* @param attempt - Current retry attempt (internal)
* @param dimensions - Optional output-vector size. When provided, sent as
* the `dimensions` field in the request body. Endpoints that implement
* Matryoshka truncation (OpenAI text-embedding-3-*, Cohere embed-v3,
* Voyage) return a truncated vector at that size; endpoints that do not
* recognise the field may ignore it or return 400. Leave
* `GITNEXUS_EMBEDDING_DIMS` unset for strict backends that reject
* unknown fields.
*/
const httpEmbedBatch = async (
url: string,
@ -99,7 +108,16 @@ const httpEmbedBatch = async (
model: string,
apiKey: string,
batchIndex = 0,
dimensions?: number,
): Promise<EmbeddingItem[]> => {
const requestBody: { input: string[]; model: string; dimensions?: number } = {
input: batch,
model,
};
if (dimensions !== undefined) {
requestBody.dimensions = dimensions;
}
let resp: Response;
try {
resp = await resilientFetch(
@ -111,7 +129,7 @@ const httpEmbedBatch = async (
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({ input: batch, model }),
body: JSON.stringify(requestBody),
},
{
breakerKey: HTTP_BREAKER_KEY,
@ -169,7 +187,14 @@ export const httpEmbed = async (texts: string[]): Promise<Float32Array[]> => {
for (let i = 0; i < texts.length; i += HTTP_BATCH_SIZE) {
const batch = texts.slice(i, i + HTTP_BATCH_SIZE);
const batchIndex = Math.floor(i / HTTP_BATCH_SIZE);
const items = await httpEmbedBatch(url, batch, config.model, config.apiKey, batchIndex);
const items = await httpEmbedBatch(
url,
batch,
config.model,
config.apiKey,
batchIndex,
config.dimensions,
);
if (items.length !== batch.length) {
throw new Error(
@ -212,7 +237,14 @@ export const httpEmbedQuery = async (text: string): Promise<number[]> => {
if (!config) throw new Error('HTTP embedding not configured');
const url = `${config.baseUrl}/embeddings`;
const items = await httpEmbedBatch(url, [text], config.model, config.apiKey);
const items = await httpEmbedBatch(
url,
[text],
config.model,
config.apiKey,
0,
config.dimensions,
);
if (!items.length) {
throw new Error(`Embedding endpoint returned empty response (${safeUrl(url)})`);
}

View file

@ -5,6 +5,7 @@ import { createIgnoreFilter } from '../../../config/ignore-service.js';
import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js';
import type { ExtractedContract, RepoHandle } from '../types.js';
import { readSafe } from './fs-utils.js';
import { parseSourceSafe } from '../../tree-sitter/safe-parse.js';
import { logger } from '../../logger.js';
import {
GRPC_SCAN_GLOB,
@ -428,7 +429,7 @@ export class GrpcExtractor implements ContractExtractor {
let detections: GrpcDetection[] = [];
try {
parser.setLanguage(plugin.language);
const tree = parser.parse(content);
const tree = parseSourceSafe(parser, content);
detections = plugin.scan(tree);
} catch {
continue;

View file

@ -1,3 +1,4 @@
import type Parser from 'tree-sitter';
import Python from 'tree-sitter-python';
import {
compilePatterns,
@ -12,6 +13,7 @@ import type { HttpDetection, HttpLanguagePlugin } from './types.js';
* - FastAPI `@app.get("/path")` provider decorators
* - `requests.get/post/...("url")` consumer calls
* - Generic `requests.request("METHOD", "url")` consumer calls
* - `httpx.AsyncClient` instances calling `.get/.post/...("url")`
*/
const FASTAPI_VERBS: Record<string, string> = {
@ -77,11 +79,161 @@ const REQUESTS_GENERIC_PATTERNS = compilePatterns({
],
} satisfies LanguagePatterns<Record<string, never>>);
// ─── Consumer: httpx.AsyncClient assignments ────────────────────────
// NOTE: This targeted detector only tracks explicit `httpx.AsyncClient(...)`
// construction. Direct imports (`from httpx import AsyncClient`) and module
// aliases (`import httpx as hx`) and annotated assignments (`client: httpx.AsyncClient = ...`)
// are intentionally left for a follow-up. Module-scope clients are only matched
// at module scope; calls inside functions require a function/class-local tracked
// client to avoid false positives from same-name local variables.
const HTTPX_ASYNC_CLIENT_ASSIGN_PATTERNS = compilePatterns({
name: 'python-httpx-async-client-assign',
language: Python,
patterns: [
{
meta: {},
query: `
(assignment
left: (_) @client
right: (call
function: (attribute
object: (identifier) @module (#eq? @module "httpx")
attribute: (identifier) @client_class (#eq? @client_class "AsyncClient"))))
`,
},
],
} satisfies LanguagePatterns<Record<string, never>>);
// ─── Consumer: async with httpx.AsyncClient() as client ──────────────
const HTTPX_ASYNC_CLIENT_WITH_ALIAS_PATTERNS = compilePatterns({
name: 'python-httpx-async-client-with-alias',
language: Python,
patterns: [
{
meta: {},
query: `
(as_pattern
(call
function: (attribute
object: (identifier) @module (#eq? @module "httpx")
attribute: (identifier) @client_class (#eq? @client_class "AsyncClient")))
(as_pattern_target (identifier) @client))
`,
},
],
} satisfies LanguagePatterns<Record<string, never>>);
function getScopeKey(node: Parser.SyntaxNode | null, preferClass = false): string {
if (preferClass) {
let current: Parser.SyntaxNode | null = node;
while (current) {
if (current.type === 'class_definition') {
return `class:${current.startIndex}:${current.endIndex}`;
}
current = current.parent;
}
}
let current: Parser.SyntaxNode | null = node;
while (current) {
if (current.type === 'function_definition') {
return `function:${current.startIndex}:${current.endIndex}`;
}
current = current.parent;
}
return 'module';
}
function trackedClientScopeKey(clientNode: Parser.SyntaxNode): string {
return getScopeKey(clientNode.parent, clientNode.text.includes('.'));
}
function callScopeKeys(clientNode: Parser.SyntaxNode): string[] {
const keys = new Set<string>();
const preferClass = clientNode.text.includes('.');
const nearestScope = getScopeKey(clientNode.parent, preferClass);
keys.add(nearestScope);
return [...keys];
}
function collectHttpxAsyncClients(tree: Parser.Tree): Map<string, Set<string>> {
const clients = new Map<string, Set<string>>();
const addClient = (clientNode: Parser.SyntaxNode | undefined) => {
if (!clientNode) return;
const scopeKey = trackedClientScopeKey(clientNode);
const clientText = clientNode.text;
const scopes = clients.get(clientText) ?? new Set<string>();
scopes.add(scopeKey);
clients.set(clientText, scopes);
};
for (const match of runCompiledPatterns(HTTPX_ASYNC_CLIENT_ASSIGN_PATTERNS, tree)) {
addClient(match.captures.client);
}
for (const match of runCompiledPatterns(HTTPX_ASYNC_CLIENT_WITH_ALIAS_PATTERNS, tree)) {
addClient(match.captures.client);
}
return clients;
}
function hasTrackedHttpxAsyncClient(
clients: Map<string, Set<string>>,
clientNode: Parser.SyntaxNode,
): boolean {
const scopes = clients.get(clientNode.text);
if (!scopes) return false;
return callScopeKeys(clientNode).some((scopeKey) => scopes.has(scopeKey));
}
// ─── Consumer: httpx AsyncClient .get/.post/...("url") ──────────────
const HTTPX_ASYNC_CLIENT_VERB_PATTERNS = compilePatterns({
name: 'python-httpx-async-client-verb',
language: Python,
patterns: [
{
meta: {},
query: `
(call
function: (attribute
object: (_) @client
attribute: (identifier) @method (#match? @method "^(get|post|put|delete|patch)$"))
arguments: (argument_list . (string) @path))
`,
},
],
} satisfies LanguagePatterns<Record<string, never>>);
// ─── Consumer: httpx AsyncClient .request("METHOD", "url") ─────────
const HTTPX_ASYNC_CLIENT_GENERIC_PATTERNS = compilePatterns({
name: 'python-httpx-async-client-generic',
language: Python,
patterns: [
{
meta: {},
query: `
(call
function: (attribute
object: (_) @client
attribute: (identifier) @method (#eq? @method "request"))
arguments: (argument_list . (string) @http_method (string) @path))
`,
},
],
} satisfies LanguagePatterns<Record<string, never>>);
export const PYTHON_HTTP_PLUGIN: HttpLanguagePlugin = {
name: 'python-http',
language: Python,
scan(tree) {
const out: HttpDetection[] = [];
const httpxAsyncClients = collectHttpxAsyncClients(tree);
// Providers: FastAPI
for (const match of runCompiledPatterns(FASTAPI_PATTERNS, tree)) {
@ -137,6 +289,45 @@ export const PYTHON_HTTP_PLUGIN: HttpLanguagePlugin = {
});
}
// Consumers: httpx.AsyncClient.<verb>("url")
for (const match of runCompiledPatterns(HTTPX_ASYNC_CLIENT_VERB_PATTERNS, tree)) {
const clientNode = match.captures.client;
const methodNode = match.captures.method;
const pathNode = match.captures.path;
if (!clientNode || !methodNode || !pathNode) continue;
if (!hasTrackedHttpxAsyncClient(httpxAsyncClients, clientNode)) continue;
const path = unquoteLiteral(pathNode.text);
if (path === null) continue;
out.push({
role: 'consumer',
framework: 'python-httpx',
method: methodNode.text.toUpperCase(),
path,
name: null,
confidence: 0.7,
});
}
// Consumers: httpx.AsyncClient.request("METHOD", "url")
for (const match of runCompiledPatterns(HTTPX_ASYNC_CLIENT_GENERIC_PATTERNS, tree)) {
const clientNode = match.captures.client;
const methodNode = match.captures.http_method;
const pathNode = match.captures.path;
if (!clientNode || !methodNode || !pathNode) continue;
if (!hasTrackedHttpxAsyncClient(httpxAsyncClients, clientNode)) continue;
const methodRaw = unquoteLiteral(methodNode.text);
const path = unquoteLiteral(pathNode.text);
if (methodRaw === null || path === null) continue;
out.push({
role: 'consumer',
framework: 'python-httpx',
method: methodRaw.toUpperCase(),
path,
name: null,
confidence: 0.7,
});
}
return out;
},
};

View file

@ -5,6 +5,7 @@ import { createIgnoreFilter } from '../../../config/ignore-service.js';
import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js';
import type { ExtractedContract, RepoHandle } from '../types.js';
import { readSafe } from './fs-utils.js';
import { parseSourceSafe } from '../../tree-sitter/safe-parse.js';
import { getPluginForFile, HTTP_SCAN_GLOB, type HttpDetection } from './http-patterns/index.js';
/**
@ -172,7 +173,7 @@ export class HttpRouteExtractor implements ContractExtractor {
}
try {
parser.setLanguage(plugin.language);
const tree = parser.parse(content);
const tree = parseSourceSafe(parser, content);
const detections = plugin.scan(tree);
cachedDetections.set(rel, detections);
return detections;

View file

@ -10,6 +10,7 @@ import { readSafe } from './fs-utils.js';
import { buildSuffixIndex, type SuffixIndex } from '../../ingestion/import-resolvers/utils.js';
import { createIgnoreFilter } from '../../../config/ignore-service.js';
import { getMaxFileSizeBytes } from '../../ingestion/utils/max-file-size.js';
import { parseSourceSafe } from '../../tree-sitter/safe-parse.js';
import { logger } from '../../logger.js';
/**
@ -505,7 +506,7 @@ export class IncludeExtractor implements ContractExtractor {
let extractionSource: 'tree_sitter' | 'regex_fallback';
try {
parser.setLanguage(lang);
const tree = parser.parse(content);
const tree = parseSourceSafe(parser, content);
let matches: Parser.QueryMatch[];
try {
matches = query.matches(tree.rootNode);

View file

@ -3,6 +3,7 @@ import Parser from 'tree-sitter';
import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js';
import type { ExtractedContract, RepoHandle } from '../types.js';
import { readSafe } from './fs-utils.js';
import { parseSourceSafe } from '../../tree-sitter/safe-parse.js';
import {
getPluginForFile,
THRIFT_SCAN_GLOB,
@ -311,7 +312,7 @@ export class ThriftExtractor implements ContractExtractor {
let detections: ThriftDetection[] = [];
try {
parser.setLanguage(plugin.language);
const tree = parser.parse(content);
const tree = parseSourceSafe(parser, content);
detections = plugin.scan(tree);
} catch {
continue;

View file

@ -1,4 +1,5 @@
import Parser from 'tree-sitter';
import { parseSourceSafe } from '../../tree-sitter/safe-parse.js';
/**
* Shared, language-agnostic tree-sitter scanning utilities used by group
@ -155,7 +156,7 @@ export function scanFile<TMeta>(
let tree: Parser.Tree;
try {
parser.setLanguage(plugin.language);
tree = parser.parse(content);
tree = parseSourceSafe(parser, content);
} catch {
return [];
}

View file

@ -0,0 +1,76 @@
/**
* Shadow-candidate path derivation for incremental indexing.
*
* Background Bugbot review on PR #1479:
* queryImporters() on a NEWLY ADDED file returns 0 importers in the
* pre-pipeline DB, because the new file's IMPORTS rows haven't been
* written yet. But pre-existing files may have IMPORTS edges that
* *resolved to a sibling path*, and the newcomer can now steal that
* resolution under standard JS/TS module-resolution rules. Without
* pulling those pre-existing files into the writable set, their
* stale CALLS edges remain pointing at the OLD resolution target.
*
* Given an added file path, this helper enumerates the pre-existing
* file paths whose import-resolution claim the newcomer can steal.
* Caller filters the candidates against the prior-run `fileHashes`
* map so we only query importers of paths that actually existed.
*
* Shadow patterns covered (resolution-priority-aware):
*
* (a) Same basename, different extension
* added `foo/bar.ts` shadows `foo/bar.{tsx,js,jsx,mjs,cjs,d.ts}`.
* (b) Bare-file beats directory-style index
* added `foo/bar.ts` shadows `foo/bar/index.{ts,tsx,...}`.
* (c) Directory-index beats bare-file
* added `foo/index.ts` shadows `foo.{ts,tsx,...}` (rare but real,
* e.g. converting a single-file module into a directory module).
*
* Resolution-order priority is conservatively wide: we enumerate ALL
* common extensions because we don't know which the importer actually
* specified, and over-seeding is harmless (extra BFS work, but the
* subgraph extract still gates write-back by file membership).
*
* Cross-platform path separators: candidates are emitted with both `/`
* and `\` for shadow pattern (b), since the caller's prior fileHashes
* map may use either depending on the OS that wrote it.
*/
const SHADOW_EXTS = ['.d.ts', '.tsx', '.ts', '.jsx', '.js', '.mjs', '.cjs'];
/**
* Enumerate pre-existing paths whose import-resolution `added` can steal.
*
* @param added repo-relative path of a newly-added file
* @returns deduplicated list of candidate paths (NOT filtered against
* any known-files set caller does that)
*/
export const shadowCandidatesFor = (added: string): string[] => {
const ext = SHADOW_EXTS.find((e) => added.endsWith(e));
if (!ext) return [];
const noExt = added.slice(0, -ext.length);
const out = new Set<string>();
// (a) Same basename, different extension.
for (const alt of SHADOW_EXTS) {
if (alt !== ext) out.add(noExt + alt);
}
// (b) Bare file beats sibling directory-style index.
for (const idx of SHADOW_EXTS) {
out.add(`${noExt}/index${idx}`);
out.add(`${noExt}\\index${idx}`);
}
// (c) New `foo/index.ext` shadows old `foo.ext`.
const idxSuffixSlash = '/index';
const idxSuffixBack = '\\index';
let dir: string | null = null;
if (noExt.endsWith(idxSuffixSlash)) dir = noExt.slice(0, -idxSuffixSlash.length);
else if (noExt.endsWith(idxSuffixBack)) dir = noExt.slice(0, -idxSuffixBack.length);
if (dir !== null) {
for (const alt of SHADOW_EXTS) out.add(dir + alt);
}
return [...out];
};

View file

@ -0,0 +1,123 @@
/**
* Subgraph extraction for incremental DB writeback.
*
* Given the FULL ctx.graph produced by the pipeline (all files parsed,
* all phases run) and the set of file paths whose DB rows must be
* replaced, produce a smaller KnowledgeGraph that contains:
*
* - Every node whose `properties.filePath` is in `toWriteSet`.
* - Every graph-wide node (Community, Process) these are regenerated
* each run by the communities/processes phases and must be fully
* rewritten.
* - Every relationship where AT LEAST ONE endpoint is in the writable
* set above. Relationships entirely between unchanged-file nodes
* are skipped their rows are still in the DB and re-inserting
* them would PK-conflict at COPY time.
*
* The resulting subgraph is what gets passed to `loadGraphToLbug` after
* the orchestrator has deleted the corresponding DB rows. Hydrated
* unchanged-file rows are never touched in the DB.
*
* # Cross-file edge consistency (Finding 1)
*
* `extractChangedSubgraph` intentionally does NOT expand the set it is
* given expansion is the orchestrator's job, so the SAME expanded set
* can be fed to both `deleteNodesForFile` and this function (asymmetry
* between the delete set and the write set silently corrupts the DB).
* `computeEffectiveWriteSet` below performs the boundary-crossing 1-hop
* walk; the orchestrator composes it with its importer-BFS expansion and
* passes the result here.
*
* Why the 1-hop walk is needed: consider a barrel re-export change
* file C (a barrel) shifts `export { foo } from './b'` to
* `export { foo } from './d'`. After scope resolution, file A's CALLS
* edge to `foo` resolves to D instead of B, even though A's content is
* byte-for-byte identical:
*
* - Old AB edge survives in DB (neither A nor B is changed not deleted)
* - New AD edge is missing (neither A nor D in writable set skipped)
*
* Pulling the unchanged-side file of every writable-boundary-crossing
* edge into the write set fixes both halves: the orchestrator's
* `DETACH DELETE` cleans up the stale unchanged-side rows, and the new
* cross-file edges land because at least one endpoint is now writable.
*
* Limitation (documented): if a file X *stopped* importing from a
* changed file C, X has no edge to C in the new graph, so this 1-hop
* walk doesn't catch it. The orchestrator's importer-BFS (which reads
* IMPORTS from the pre-pipeline DB) covers that case instead.
*/
import type { GraphNode, GraphRelationship } from 'gitnexus-shared';
import { createKnowledgeGraph } from '../graph/graph.js';
import type { KnowledgeGraph } from '../graph/types.js';
const isGraphWide = (label: string): boolean => label === 'Community' || label === 'Process';
/**
* Build a Map<nodeId, filePath> for every File-bound node in the graph.
* Graph-wide nodes (Community/Process) have no filePath and are filtered.
*/
const indexNodeFilePaths = (fullGraph: KnowledgeGraph): Map<string, string> => {
const idx = new Map<string, string>();
fullGraph.forEachNode((n: GraphNode) => {
const fp = n.properties?.filePath as string | undefined;
if (fp) idx.set(n.id, fp);
});
return idx;
};
export const extractChangedSubgraph = (
fullGraph: KnowledgeGraph,
toWriteSet: ReadonlySet<string>,
): KnowledgeGraph => {
const sub = createKnowledgeGraph();
const writableNodeIds = new Set<string>();
fullGraph.forEachNode((n: GraphNode) => {
const filePath = n.properties?.filePath as string | undefined;
const include = (filePath && toWriteSet.has(filePath)) || isGraphWide(n.label);
if (include) {
sub.addNode(n);
writableNodeIds.add(n.id);
}
});
fullGraph.forEachRelationship((r: GraphRelationship) => {
if (writableNodeIds.has(r.sourceId) || writableNodeIds.has(r.targetId)) {
sub.addRelationship(r);
}
});
return sub;
};
/**
* Public derive the EFFECTIVE write-set: `toWriteSet` expanded by one
* hop along every edge in the new graph that crosses the writable
* boundary (one endpoint in a writable file, the other in an unchanged
* file). The unchanged-side file is pulled in so its stale rows are
* deleted + rewritten in lockstep with the changed side.
*
* Single pass over the edge list. Does NOT mutate `toWriteSet`. The
* orchestrator MUST feed the returned set to both `deleteNodesForFile`
* and `extractChangedSubgraph` feeding the unexpanded set to either
* one leaves stale rows or PK-conflicts at COPY time.
*/
export const computeEffectiveWriteSet = (
fullGraph: KnowledgeGraph,
toWriteSet: ReadonlySet<string>,
): Set<string> => {
const nodeFilePaths = indexNodeFilePaths(fullGraph);
const expanded = new Set<string>(toWriteSet);
fullGraph.forEachRelationship((r: GraphRelationship) => {
const sourcePath = nodeFilePaths.get(r.sourceId);
const targetPath = nodeFilePaths.get(r.targetId);
if (!sourcePath || !targetPath) return; // skip edges to graph-wide nodes
const sourceWritable = toWriteSet.has(sourcePath);
const targetWritable = toWriteSet.has(targetPath);
if (sourceWritable && !targetWritable) expanded.add(targetPath);
else if (targetWritable && !sourceWritable) expanded.add(sourcePath);
});
return expanded;
};

View file

@ -40,13 +40,16 @@ import { getLanguageFromFilename, SupportedLanguages } from 'gitnexus-shared';
import { isRegistryPrimary } from './registry-primary-flag.js';
import { isVerboseIngestionEnabled } from './utils/verbose.js';
import { yieldToEventLoop } from './utils/event-loop.js';
import { parseSourceSafe } from '../tree-sitter/safe-parse.js';
import {
CLASS_CONTAINER_TYPES,
FUNCTION_NODE_TYPES,
findEnclosingClassId,
findEnclosingClassInfo,
genericFuncName,
inferFunctionLabel,
} from './utils/ast-helpers.js';
import type { FieldInfo, FieldExtractorContext } from './field-types.js';
import type { LanguageProvider } from './language-provider.js';
import { typeTagForId, constTagForId, buildCollisionGroups } from './utils/method-props.js';
import type { MethodInfo } from './method-types.js';
import {
@ -76,6 +79,62 @@ import type { LiteralTypeInferrer } from './type-extractors/types.js';
import type { SyntaxNode } from './utils/ast-helpers.js';
import { logger } from '../logger.js';
// ── Property-prepass helpers (parity with parse-worker.ts) ──
// These mirror the sequential-path equivalents in parse-worker.ts so the main-
// thread `processCalls` pre-pass produces byte-identical Property nodes/symbols
// to the worker pool. Drift between the two paths breaks the
// `incremental ≡ --force` invariant the moment a repo crosses the worker
// threshold between runs.
/** Walk up to the nearest enclosing class/struct/interface AST node. */
const findEnclosingClassNode = (node: SyntaxNode): SyntaxNode | null => {
let current = node.parent;
while (current) {
if (CLASS_CONTAINER_TYPES.has(current.type)) return current;
current = current.parent;
}
return null;
};
/** No-op SymbolTable stub for FieldExtractorContext — matches parse-worker. */
const NOOP_SYMBOL_TABLE: SymbolTableReader = {
lookupExact: () => undefined,
lookupExactFull: () => undefined,
lookupExactAll: () => [],
lookupCallableByName: () => [],
getFiles: () => [][Symbol.iterator](),
getStats: () => ({ fileCount: 0 }),
};
/**
* Extract (and cache) field info for a class node. Cache is passed in so it
* stays scoped to a single `processCalls` invocation rather than leaking
* across analyze runs (worker uses module-level caching because each worker
* process is short-lived; the main thread is not).
*
* Cache key is `${filePath}:${classNode.startIndex}` startIndex alone is a
* per-file byte offset, so almost every Ruby/Python file's leading class lands
* at byte 0 and would collide across files in the shared map.
*/
const getFieldInfo = (
classNode: SyntaxNode,
provider: LanguageProvider,
context: FieldExtractorContext,
cache: Map<string, Map<string, FieldInfo>>,
): Map<string, FieldInfo> | undefined => {
if (!provider.fieldExtractor) return undefined;
const cacheKey = `${context.filePath}:${classNode.startIndex}`;
const cached = cache.get(cacheKey);
if (cached) return cached;
const result = provider.fieldExtractor.extract(classNode, context);
if (!result?.fields?.length) return undefined;
const map = new Map<string, FieldInfo>();
for (const field of result.fields) map.set(field.name, field);
cache.set(cacheKey, map);
return map;
};
/** Per-file resolved type bindings for exported symbols.
* Populated during call processing, consumed by Phase 14 re-resolution pass. */
export type ExportedTypeMap = Map<string, Map<string, string>>;
@ -771,7 +830,7 @@ export const processCalls = async (
if (!tree) {
const parseContent = provider.preprocessSource?.(file.content, file.path) ?? file.content;
try {
tree = parser.parse(parseContent, undefined, {
tree = parseSourceSafe(parser, parseContent, undefined, {
bufferSize: getTreeSitterBufferSize(parseContent),
});
} catch (parseError) {
@ -859,6 +918,120 @@ export const processCalls = async (
prepared.push({ file, language, provider, tree, matches, parentMap, typeEnv });
}
// ── Property-registration pre-pass ──
// Register all routed properties (e.g. Ruby attr_accessor) BEFORE the
// resolution loop so cross-file field-type lookups (e.g.
// `user.address.save → Address#save`) succeed regardless of file
// processing order. This MUST stay in lockstep with the equivalent
// worker-path block in parse-worker.ts (kind === 'properties') — any
// divergence between the two paths breaks the `incremental ≡ --force`
// invariant once a repo crosses the worker threshold between runs.
const fieldInfoCache = new Map<string, Map<string, FieldInfo>>();
for (const { file, language, provider, matches, typeEnv } of prepared) {
const callRouter = provider.callRouter;
if (!callRouter) continue;
matches.forEach((match) => {
const captureMap: Record<string, any> = {};
match.captures.forEach((c) => (captureMap[c.name] = c.node));
if (!captureMap['call']) return;
const callNameNode = captureMap['call.name'];
if (!callNameNode) return;
const routed = callRouter(callNameNode.text, captureMap['call']);
if (!routed || routed.kind !== 'properties') return;
const propEnclosingInfo = findEnclosingClassInfo(
captureMap['call'],
file.path,
provider.resolveEnclosingOwner,
);
const propEnclosingClassId = propEnclosingInfo?.classId ?? null;
// Enrich routed properties with FieldExtractor metadata so types
// discovered from constructor assignments (e.g. `@address = Address.new`)
// are propagated even when the routing payload itself lacks declaredType.
let routedFieldMap: Map<string, FieldInfo> | undefined;
if (provider.fieldExtractor && typeEnv) {
const classNode = findEnclosingClassNode(captureMap['call']);
if (classNode) {
routedFieldMap = getFieldInfo(
classNode,
provider,
{
typeEnv,
symbolTable: NOOP_SYMBOL_TABLE,
filePath: file.path,
language,
},
fieldInfoCache,
);
}
}
const fileId = generateId('File', file.path);
for (const item of routed.items) {
const routedFieldInfo = routedFieldMap?.get(item.propName);
const propQualifiedName = propEnclosingInfo
? `${propEnclosingInfo.className}.${item.propName}`
: item.propName;
const nodeId = generateId('Property', `${file.path}:${propQualifiedName}`);
graph.addNode({
id: nodeId,
label: 'Property',
properties: {
name: item.propName,
filePath: file.path,
startLine: item.startLine,
endLine: item.endLine,
language,
isExported: true,
description: item.accessorType,
...(item.declaredType
? { declaredType: item.declaredType }
: routedFieldInfo?.type
? { declaredType: routedFieldInfo.type }
: {}),
...(routedFieldInfo?.visibility !== undefined
? { visibility: routedFieldInfo.visibility }
: {}),
...(routedFieldInfo?.isStatic !== undefined
? { isStatic: routedFieldInfo.isStatic }
: {}),
...(routedFieldInfo?.isReadonly !== undefined
? { isReadonly: routedFieldInfo.isReadonly }
: {}),
},
});
ctx.model.symbols.add(file.path, item.propName, nodeId, 'Property', {
...(propEnclosingClassId ? { ownerId: propEnclosingClassId } : {}),
...(item.declaredType
? { declaredType: item.declaredType }
: routedFieldInfo?.type
? { declaredType: routedFieldInfo.type }
: {}),
});
const relId = generateId('DEFINES', `${fileId}->${nodeId}`);
graph.addRelationship({
id: relId,
sourceId: fileId,
targetId: nodeId,
type: 'DEFINES',
confidence: 1.0,
reason: '',
});
if (propEnclosingClassId) {
graph.addRelationship({
id: generateId('HAS_PROPERTY', `${propEnclosingClassId}->${nodeId}`),
sourceId: propEnclosingClassId,
targetId: nodeId,
type: 'HAS_PROPERTY',
confidence: 1.0,
reason: '',
});
}
}
});
}
// ── Resolution loop: verify constructor bindings and resolve calls ──
// The accumulator (if present) is now fully populated from the preparation
// loop above, so verifyConstructorBindings sees all provider bindings
@ -929,9 +1102,10 @@ export const processCalls = async (
provider,
);
const srcId = enclosing || generateId('File', file.path);
// Defer resolution: Ruby attr_accessor properties are registered during
// this same loop, so cross-file lookups fail if the declaring file hasn't
// been processed yet. Collect now, resolve after all files are done.
// Defer resolution so write-access tracking sees the FINAL graph
// state — properties from the pre-pass are present, but receiver-type
// resolution can still depend on inference that completes during the
// main loop. Resolve after all files have been processed.
pendingWrites.push({ receiverTypeName, propertyName, filePath: file.path, srcId });
}
// Assignment-only capture (no @call sibling): skip the rest of this
@ -1052,47 +1226,8 @@ export const processCalls = async (
return;
case 'properties': {
const fileId = generateId('File', file.path);
const propEnclosingClassId = findEnclosingClassId(captureMap['call'], file.path);
for (const item of routed.items) {
const nodeId = generateId('Property', `${file.path}:${item.propName}`);
graph.addNode({
id: nodeId,
label: 'Property',
properties: {
name: item.propName,
filePath: file.path,
startLine: item.startLine,
endLine: item.endLine,
language,
isExported: true,
description: item.accessorType,
},
});
ctx.model.symbols.add(file.path, item.propName, nodeId, 'Property', {
...(propEnclosingClassId ? { ownerId: propEnclosingClassId } : {}),
...(item.declaredType ? { declaredType: item.declaredType } : {}),
});
const relId = generateId('DEFINES', `${fileId}->${nodeId}`);
graph.addRelationship({
id: relId,
sourceId: fileId,
targetId: nodeId,
type: 'DEFINES',
confidence: 1.0,
reason: '',
});
if (propEnclosingClassId) {
graph.addRelationship({
id: generateId('HAS_PROPERTY', `${propEnclosingClassId}->${nodeId}`),
sourceId: propEnclosingClassId,
targetId: nodeId,
type: 'HAS_PROPERTY',
confidence: 1.0,
reason: '',
});
}
}
// Properties already registered in the pre-pass above.
// Skip to avoid duplicate nodes/edges.
return;
}
@ -3283,7 +3418,7 @@ export const extractFetchCallsFromFiles = async (
if (!tree) {
const parseContent = provider.preprocessSource?.(file.content, file.path) ?? file.content;
try {
tree = parser.parse(parseContent, undefined, {
tree = parseSourceSafe(parser, parseContent, undefined, {
bufferSize: getTreeSitterBufferSize(parseContent),
});
} catch {

View file

@ -41,6 +41,24 @@ interface LeidenDetailedResult {
modularity: number;
}
/**
* Deterministic PRNG (mulberry32) seed for the vendored Leiden algorithm.
* Vendored Leiden defaults `rng: Math.random`, which makes community
* assignment non-deterministic across runs. Passing a seeded RNG gives us
* reproducible community/modularity output, which is required for the
* incremental-indexing equivalence test (incremental full rebuild).
*/
const LEIDEN_SEED = 0xc0de;
function createSeededRng(seed: number): () => number {
let s = seed >>> 0;
return () => {
s = (s + 0x6d2b79f5) >>> 0;
let t = Math.imul(s ^ (s >>> 15), 1 | s);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// ============================================================================
// TYPES
// ============================================================================
@ -150,6 +168,7 @@ export const processCommunities = async (
leiden.detailed(graph, {
resolution: isLarge ? 2.0 : 1.0,
maxIterations: isLarge ? 3 : 0,
rng: createSeededRng(LEIDEN_SEED),
}),
),
new Promise<never>((_, reject) =>

View file

@ -22,6 +22,7 @@ import { generateId } from '../../lib/utils.js';
import { getLanguageFromFilename, type NodeLabel, type SupportedLanguages } from 'gitnexus-shared';
import { isVerboseIngestionEnabled } from './utils/verbose.js';
import { yieldToEventLoop } from './utils/event-loop.js';
import { parseSourceSafe } from '../tree-sitter/safe-parse.js';
import { getProvider } from './languages/index.js';
import { getTreeSitterBufferSize } from './constants.js';
import type {
@ -224,7 +225,7 @@ export const processHeritage = async (
// re-parses see the same input as the cached AST.
const parseContent = provider.preprocessSource?.(file.content, file.path) ?? file.content;
try {
tree = parser.parse(parseContent, undefined, {
tree = parseSourceSafe(parser, parseContent, undefined, {
bufferSize: getTreeSitterBufferSize(parseContent),
});
} catch (parseError) {
@ -419,7 +420,7 @@ export async function extractExtractedHeritageFromFiles(
if (!tree) {
const parseContent = provider.preprocessSource?.(file.content, file.path) ?? file.content;
try {
tree = parser.parse(parseContent, undefined, {
tree = parseSourceSafe(parser, parseContent, undefined, {
bufferSize: getTreeSitterBufferSize(parseContent),
});
} catch {

View file

@ -8,6 +8,7 @@ import { generateId } from '../../lib/utils.js';
import { getLanguageFromFilename } from 'gitnexus-shared';
import { isVerboseIngestionEnabled } from './utils/verbose.js';
import { yieldToEventLoop } from './utils/event-loop.js';
import { parseSourceSafe } from '../tree-sitter/safe-parse.js';
import type { ExtractedImport } from './workers/parse-worker.js';
import { getTreeSitterBufferSize } from './constants.js';
import { loadImportConfigs } from './language-config.js';
@ -307,7 +308,7 @@ export const processImports = async (
if (!tree) {
const parseContent = provider.preprocessSource?.(file.content, file.path) ?? file.content;
try {
tree = parser.parse(parseContent, undefined, {
tree = parseSourceSafe(parser, parseContent, undefined, {
bufferSize: getTreeSitterBufferSize(parseContent),
});
} catch (parseError) {

View file

@ -128,7 +128,21 @@ export const resolveImportPath = (
if (importPath.startsWith('.')) {
const resolved = tryResolveWithExtensions(basePath, allFiles);
return cache(resolved);
if (resolved) return cache(resolved);
// TypeScript ESM: imports use .js/.jsx/.mjs/.cjs but source files are
// .ts/.tsx/.mts/.cts. Strip the JS-family extension and re-resolve.
// NOTE: This fallback only applies to relative imports. Path alias imports
// (e.g. @/utils.js via tsconfig paths) do not yet strip .js extensions —
// that is a known limitation tracked for follow-up.
if (language === SupportedLanguages.TypeScript || language === SupportedLanguages.JavaScript) {
const stripped = stripJsExtension(basePath);
if (stripped !== null) {
return cache(tryResolveWithExtensions(stripped, allFiles));
}
}
return cache(null);
}
// ---- Generic package/absolute import resolution (suffix matching) ----
@ -182,3 +196,19 @@ export function resolveStandard(
export function createStandardStrategy(language: SupportedLanguages): ImportResolverStrategy {
return (raw, fp, ctx) => resolveStandard(raw, fp, ctx, language);
}
// ============================================================================
// ESM extension helpers
// ============================================================================
/** JS-family extensions that TypeScript ESM maps to TS equivalents. */
const JS_EXTENSION_PATTERN = /\.(js|jsx|mjs|cjs)$/;
/**
* Strip a JS-family extension from a path, returning the stem.
* Returns `null` if the path does not end with a JS-family extension.
*/
export function stripJsExtension(path: string): string | null {
const match = JS_EXTENSION_PATTERN.exec(path);
return match ? path.slice(0, -match[0].length) : null;
}

View file

@ -9,8 +9,12 @@ export const EXTENSIONS = [
// TypeScript/JavaScript
'.tsx',
'.ts',
'.mts',
'.cts',
'.jsx',
'.js',
'.mjs',
'.cjs',
'.vue',
'/index.tsx',
'/index.ts',

View file

@ -46,6 +46,15 @@ import { createCallExtractor } from '../call-extractors/generic.js';
import { cCallConfig, cppCallConfig } from '../call-extractors/configs/c-cpp.js';
import { createHeritageExtractor } from '../heritage-extractors/generic.js';
import { stripUeMacros } from '../cpp-ue-preprocessor.js';
import {
emitCScopeCaptures,
interpretCImport,
interpretCTypeBinding,
cArityCompatibility,
cBindingScopeFor,
cImportOwningScope,
cReceiverBinding,
} from './c/index.js';
const C_BUILT_INS: ReadonlySet<string> = new Set([
'printf',
@ -367,6 +376,16 @@ export const cProvider = defineLanguage({
heritageExtractor: createHeritageExtractor(SupportedLanguages.C),
labelOverride: cppLabelOverride,
builtInNames: C_BUILT_INS,
// ── RFC #909 Ring 3: scope-based resolution hooks (RFC §5) ──────────
emitScopeCaptures: emitCScopeCaptures,
interpretImport: interpretCImport,
interpretTypeBinding: interpretCTypeBinding,
bindingScopeFor: cBindingScopeFor,
importOwningScope: cImportOwningScope,
receiverBinding: cReceiverBinding,
arityCompatibility: cArityCompatibility,
// mergeBindings + resolveImportTarget live on ScopeResolver (see c/scope-resolver.ts).
});
export const cppProvider = defineLanguage({

View file

@ -0,0 +1,102 @@
import type { SyntaxNode } from '../../utils/ast-helpers.js';
export interface CArityInfo {
parameterCount?: number;
requiredParameterCount?: number;
parameterTypes?: string[];
}
/**
* Compute declaration arity from a C function definition or declaration node.
*/
export function computeCDeclarationArity(node: SyntaxNode): CArityInfo {
// Find the function_declarator child (may be wrapped in pointer_declarator)
const funcDecl = findFuncDeclarator(node);
if (funcDecl === null) return {};
const paramList = funcDecl.childForFieldName('parameters');
if (paramList === null) return {};
const params: SyntaxNode[] = [];
for (let i = 0; i < paramList.childCount; i++) {
const child = paramList.child(i);
if (child === null) continue;
if (child.type === 'parameter_declaration' || child.type === 'variadic_parameter') {
params.push(child);
}
}
// K&R old-style declaration: `int foo()` has an empty parameter_list with
// no parameter_declaration or variadic_parameter children. Per C89/C99,
// this means the function accepts an unspecified number/types of arguments —
// NOT zero arguments. Return unknown arity to avoid false 'incompatible'.
// `int foo(void)` is the explicit zero-parameter form and is handled below.
if (params.length === 0) return {};
// (void) means zero parameters
if (params.length === 1 && params[0].type === 'parameter_declaration') {
const typeNode = params[0].childForFieldName('type');
const hasDeclarator = params[0].childForFieldName('declarator') !== null;
if (typeNode !== null && typeNode.text === 'void' && !hasDeclarator) {
return { parameterCount: 0, requiredParameterCount: 0, parameterTypes: [] };
}
}
const isVariadic = params.some((p) => p.type === 'variadic_parameter');
const nonVariadicCount = params.filter((p) => p.type !== 'variadic_parameter').length;
const types: string[] = [];
for (const p of params) {
if (p.type === 'variadic_parameter') {
types.push('...');
} else {
const typeNode = p.childForFieldName('type');
types.push(typeNode?.text ?? 'unknown');
}
}
return {
parameterCount: isVariadic ? undefined : nonVariadicCount,
requiredParameterCount: nonVariadicCount,
parameterTypes: types,
};
}
/**
* Compute call-site arity from a call_expression node.
*/
export function computeCCallArity(node: SyntaxNode): number {
const argList = node.childForFieldName('arguments');
if (argList === null) return 0;
let count = 0;
for (let i = 0; i < argList.childCount; i++) {
const child = argList.child(i);
if (child === null) continue;
// Skip punctuation (commas, parens)
if (child.type !== ',' && child.type !== '(' && child.type !== ')') {
count++;
}
}
return count;
}
function findFuncDeclarator(node: SyntaxNode): SyntaxNode | null {
// Direct child
let decl = node.childForFieldName('declarator');
if (decl === null) {
for (let i = 0; i < node.childCount; i++) {
const c = node.child(i);
if (c?.type === 'function_declarator') return c;
}
return null;
}
// Unwrap pointer_declarator
while (decl.type === 'pointer_declarator') {
const next = decl.childForFieldName('declarator');
if (next === null) break;
decl = next;
}
if (decl.type === 'function_declarator') return decl;
return null;
}

View file

@ -0,0 +1,20 @@
import type { Callsite, SymbolDefinition } from 'gitnexus-shared';
/**
* C arity compatibility: no overloading. Variadic functions detected
* via '...' in parameterTypes. Otherwise exact match or unknown.
*/
export function cArityCompatibility(
def: SymbolDefinition,
callsite: Callsite,
): 'compatible' | 'unknown' | 'incompatible' {
const max = def.parameterCount;
const min = def.requiredParameterCount;
if (max === undefined && min === undefined) return 'unknown';
if (!Number.isFinite(callsite.arity) || callsite.arity < 0) return 'unknown';
const variadic = def.parameterTypes?.some((t) => t === '...') ?? false;
if (min !== undefined && callsite.arity < min) return 'incompatible';
if (max !== undefined && callsite.arity > max && !variadic) return 'incompatible';
return 'compatible';
}

View file

@ -0,0 +1,142 @@
import type { Capture, CaptureMatch } from 'gitnexus-shared';
import {
findNodeAtRange,
nodeToCapture,
syntheticCapture,
type SyntaxNode,
} from '../../utils/ast-helpers.js';
import { getCParser, getCScopeQuery } from './query.js';
import { getTreeSitterBufferSize } from '../../constants.js';
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
import { splitCInclude } from './import-decomposer.js';
import { computeCDeclarationArity, computeCCallArity } from './arity-metadata.js';
import { markStaticName } from './static-linkage.js';
export function emitCScopeCaptures(
sourceText: string,
filePath: string,
cachedTree?: unknown,
): readonly CaptureMatch[] {
let tree = cachedTree as ReturnType<ReturnType<typeof getCParser>['parse']> | undefined;
if (tree === undefined) {
tree = parseSourceSafe(getCParser(), sourceText, undefined, {
bufferSize: getTreeSitterBufferSize(sourceText),
});
}
const rawMatches = getCScopeQuery().matches(tree.rootNode);
const out: CaptureMatch[] = [];
// Track ranges where typedef-struct/union was captured as @declaration.struct/union
// so we can suppress the duplicate @declaration.typedef match at the same range.
const structTypedefRanges = new Set<string>();
for (const m of rawMatches) {
const grouped: Record<string, Capture> = {};
for (const c of m.captures) {
const tag = '@' + c.name;
if (tag.startsWith('@_')) continue;
grouped[tag] = nodeToCapture(tag, c.node);
}
if (Object.keys(grouped).length === 0) continue;
// Handle #include statements
if (grouped['@import.statement'] !== undefined) {
const anchor = grouped['@import.statement']!;
const includeNode = findNodeAtRange(tree.rootNode, anchor.range, 'preproc_include');
if (includeNode !== null) {
const split = splitCInclude(includeNode);
if (split !== null) {
out.push(split);
continue;
}
}
}
// Track typedef-struct ranges to suppress duplicate typedef declarations
const structAnchor = grouped['@declaration.struct'] ?? grouped['@declaration.union'];
if (structAnchor !== undefined) {
const r = structAnchor.range;
structTypedefRanges.add(`${r.startLine}:${r.startCol}:${r.endLine}:${r.endCol}`);
}
// Suppress @declaration.typedef if the same range was already captured as struct/union
const typedefAnchor = grouped['@declaration.typedef'];
if (typedefAnchor !== undefined) {
const r = typedefAnchor.range;
const key = `${r.startLine}:${r.startCol}:${r.endLine}:${r.endCol}`;
if (structTypedefRanges.has(key)) continue;
}
// Enrich function declarations with arity metadata and detect static linkage
const declAnchor = grouped['@declaration.function'];
if (declAnchor !== undefined) {
const fnNode =
findNodeAtRange(tree.rootNode, declAnchor.range, 'function_definition') ??
findNodeAtRange(tree.rootNode, declAnchor.range, 'declaration');
if (fnNode !== null) {
const arity = computeCDeclarationArity(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) {
grouped['@declaration.parameter-types'] = syntheticCapture(
'@declaration.parameter-types',
fnNode,
JSON.stringify(arity.parameterTypes),
);
}
// Detect static storage class (file-local linkage)
if (hasStaticStorageClass(fnNode)) {
const nameText = grouped['@declaration.name']?.text;
if (nameText !== undefined) {
markStaticName(filePath, nameText);
}
}
}
}
// Enrich call references with arity
const callAnchor = grouped['@reference.call.free'] ?? grouped['@reference.call.member'];
if (callAnchor !== undefined && grouped['@reference.arity'] === undefined) {
const callNode = findNodeAtRange(tree.rootNode, callAnchor.range, 'call_expression');
if (callNode !== null) {
grouped['@reference.arity'] = syntheticCapture(
'@reference.arity',
callNode,
String(computeCCallArity(callNode)),
);
}
}
out.push(grouped);
}
return out;
}
/**
* Check if a C function_definition or declaration has `static` storage class.
* Walks direct children for a `storage_class_specifier` node with text `static`.
*/
function hasStaticStorageClass(node: SyntaxNode): boolean {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child !== null && child.type === 'storage_class_specifier' && child.text === 'static') {
return true;
}
}
return false;
}

View file

@ -0,0 +1,58 @@
import { readdirSync, type Dirent } from 'fs';
import { join, relative } from 'path';
/** C header extensions to scan for in the workspace. */
const HEADER_EXTENSIONS = new Set(['.h']);
/**
* Walk `repoPath` recursively and return relative paths of all `.h` files.
* Used by `loadResolutionConfig` so the C resolver can resolve `#include`
* targets that live in `.h` files (classified as C++ by language detection
* but importable from `.c` files).
*/
export function scanHeaderFiles(repoPath: string): ReadonlySet<string> {
const headers = new Set<string>();
walk(repoPath, repoPath, headers);
return headers;
}
function walk(dir: string, root: string, out: Set<string>): void {
let entries: Dirent[];
try {
entries = readdirSync(dir, { withFileTypes: true, encoding: 'utf8' });
} catch {
return; // permission denied, etc.
}
for (const entry of entries) {
const name = entry.name;
const full = join(dir, name);
if (entry.isDirectory()) {
// Skip common non-source directories and build output dirs.
// Build dirs (dist, build, out, target, _build, .next, cmake-build-*)
// may contain generated headers that shadow source headers.
if (
name === 'node_modules' ||
name === '.git' ||
name === 'vendor' ||
name === 'dist' ||
name === 'build' ||
name === 'out' ||
name === 'target' ||
name === '_build' ||
name === '.next' ||
name.startsWith('cmake-build')
) {
continue;
}
walk(full, root, out);
} else if (entry.isFile()) {
const ext = name.slice(name.lastIndexOf('.'));
if (HEADER_EXTENSIONS.has(ext)) {
// Normalize to forward slashes for cross-platform consistency.
// path.relative() returns backslash-separated paths on Windows,
// but the scope-resolution pipeline uses forward slashes uniformly.
out.add(relative(root, full).replace(/\\/g, '/'));
}
}
}
}

View file

@ -0,0 +1,55 @@
import type { Capture, CaptureMatch } from 'gitnexus-shared';
import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
/**
* Decompose a `preproc_include` node into a CaptureMatch with structured
* import captures. C #include maps to a wildcard import (all symbols
* from the header are visible).
*/
export function splitCInclude(node: SyntaxNode): CaptureMatch | null {
// node.type === 'preproc_include'
// path field: (string_literal (string_content)) | (system_lib_string)
const pathNode = node.childForFieldName?.('path') ?? null;
if (pathNode === null) {
// Fallback: scan children
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child === null) continue;
if (child.type === 'string_literal' || child.type === 'system_lib_string') {
return buildIncludeCapture(node, child);
}
}
return null;
}
return buildIncludeCapture(node, pathNode);
}
function buildIncludeCapture(node: SyntaxNode, pathNode: SyntaxNode): CaptureMatch {
let raw: string;
if (pathNode.type === 'string_literal') {
// string_literal has children: `"`, string_content, `"`
// Use namedChildren to find the string_content node
const content = pathNode.namedChildren.find((c) => c.type === 'string_content');
raw = content?.text ?? pathNode.text.replace(/^"|"$/g, '');
} else {
// system_lib_string: <stdio.h> → strip angle brackets
raw = pathNode.text;
if (raw.startsWith('<') && raw.endsWith('>')) {
raw = raw.slice(1, -1);
}
}
const isSystem = pathNode.type === 'system_lib_string';
const result: Record<string, Capture> = {
'@import.statement': nodeToCapture('@import.statement', node),
'@import.kind': syntheticCapture('@import.kind', node, 'wildcard'),
'@import.source': syntheticCapture('@import.source', node, raw),
};
if (isSystem) {
result['@import.system'] = syntheticCapture('@import.system', node, 'true');
}
return result;
}

View file

@ -0,0 +1,64 @@
import { dirname, join } from 'path';
/**
* Resolve a C #include path to a file in the workspace.
*
* Strategy:
* 1. Check for a same-directory sibling relative to the including file
* (matches C compiler `#include "…"` relative-lookup semantics).
* 2. Check for an exact match (path as-is in the workspace).
* 3. Fall back to suffix matching against all workspace file paths.
* Tie-breaking: prefer the match with the fewest path components
* (closest to root). On equal depth, break ties lexicographically
* by normalized path to ensure deterministic resolution regardless
* of filesystem iteration order.
*/
export function resolveCImportTarget(
targetRaw: string,
fromFile: string,
allFilePaths: ReadonlySet<string>,
): string | null {
if (!targetRaw) return null;
const normalizedTarget = targetRaw.replace(/\\/g, '/');
// Same-directory sibling first: mirrors the C compiler's #include "…"
// relative-lookup semantics where the directory of the including
// file is searched before the include-path list.
if (fromFile) {
const siblingRaw = join(dirname(fromFile), targetRaw);
const sibling = siblingRaw.replace(/\\/g, '/');
if (allFilePaths.has(sibling)) return sibling;
// When targetRaw contains backslashes, the normalized form may
// resolve to a different sibling path — try it as well.
if (targetRaw !== normalizedTarget) {
const siblingAlt = join(dirname(fromFile), normalizedTarget);
const siblingAltNorm = siblingAlt.replace(/\\/g, '/');
if (allFilePaths.has(siblingAltNorm)) return siblingAltNorm;
}
}
// Exact match (path as-is in the workspace)
if (allFilePaths.has(normalizedTarget)) return normalizedTarget;
// Suffix match: find files ending with /targetRaw or equal to targetRaw
const suffix = '/' + normalizedTarget;
let bestMatch: string | null = null;
let bestDepth = Infinity;
let bestNormalized = '';
for (const filePath of allFilePaths) {
const normalized = filePath.replace(/\\/g, '/');
if (normalized === normalizedTarget || normalized.endsWith(suffix)) {
// Prefer shortest path (closest match)
const depth = normalized.split('/').length;
if (depth < bestDepth || (depth === bestDepth && normalized < bestNormalized)) {
bestDepth = depth;
bestMatch = filePath;
bestNormalized = normalized;
}
}
}
return bestMatch;
}

View file

@ -0,0 +1,16 @@
/**
* C scope-resolution hooks (RFC #909 Ring 3).
*/
export { emitCScopeCaptures } from './captures.js';
export { interpretCImport, interpretCTypeBinding, normalizeCTypeName } from './interpret.js';
export { splitCInclude } from './import-decomposer.js';
export { cArityCompatibility } from './arity.js';
export { cMergeBindings } from './merge-bindings.js';
export { cBindingScopeFor, cImportOwningScope, cReceiverBinding } from './simple-hooks.js';
export { resolveCImportTarget } from './import-target.js';
export {
markStaticName,
isStaticName,
clearStaticNames,
expandCWildcardNames,
} from './static-linkage.js';

View file

@ -0,0 +1,51 @@
import type { CaptureMatch, ParsedImport, ParsedTypeBinding, TypeRef } from 'gitnexus-shared';
/**
* Interpret a C #include capture into a ParsedImport.
* C includes are always wildcard imports (all symbols from the header).
*/
export function interpretCImport(captures: CaptureMatch): ParsedImport | null {
const source = captures['@import.source']?.text;
if (source === undefined) return null;
// System headers (e.g. <stdio.h>) are not resolved to local files
if (captures['@import.system'] !== undefined) return null;
return { kind: 'wildcard', targetRaw: source };
}
/**
* Interpret a C type-binding capture into a ParsedTypeBinding.
*/
export function interpretCTypeBinding(captures: CaptureMatch): ParsedTypeBinding | null {
const name = captures['@type-binding.name']?.text;
const type = captures['@type-binding.type']?.text;
if (name === undefined || type === undefined) return null;
let source: TypeRef['source'] = 'annotation';
if (captures['@type-binding.parameter'] !== undefined) {
source = 'parameter-annotation';
} else if (captures['@type-binding.assignment'] !== undefined) {
source = 'assignment-inferred';
}
return { boundName: name, rawTypeName: normalizeCTypeName(type), source };
}
/**
* Normalize a C type name: strip pointer/array syntax, qualifiers.
*/
export function normalizeCTypeName(text: string): string {
let t = text.trim();
// Strip const, volatile, restrict qualifiers
t = t.replace(/\b(const|volatile|restrict|static|extern|inline)\b/g, '').trim();
// Strip pointer stars
while (t.endsWith('*')) t = t.slice(0, -1).trim();
while (t.startsWith('*')) t = t.slice(1).trim();
// Strip array brackets
t = t.replace(/\[.*?\]/g, '').trim();
// Strip struct/union/enum prefixes
t = t.replace(/^(struct|union|enum)\s+/, '');
return t;
}

View file

@ -0,0 +1,32 @@
import type { BindingRef } from 'gitnexus-shared';
const TIER: Record<BindingRef['origin'], number> = {
local: 0,
namespace: 1,
import: 2,
reexport: 3,
wildcard: 4,
};
/**
* C merge bindings: simple first-wins by tier (local > import > wildcard).
* C has no namespaces or reexports, but the tiers are defined for
* compatibility with the shared infrastructure.
*/
export function cMergeBindings(
existing: readonly BindingRef[],
incoming: readonly BindingRef[],
_scopeId: string,
): BindingRef[] {
const seen = new Set<string>();
return [...existing, ...incoming]
.sort(
(a, b) =>
(TIER[a.origin] ?? 99) - (TIER[b.origin] ?? 99) || a.def.nodeId.localeCompare(b.def.nodeId),
)
.filter((binding) => {
if (seen.has(binding.def.nodeId)) return false;
seen.add(binding.def.nodeId);
return true;
});
}

View file

@ -0,0 +1,165 @@
import Parser from 'tree-sitter';
import C from 'tree-sitter-c';
const C_SCOPE_QUERY = `
;; Scopes
(translation_unit) @scope.module
(struct_specifier) @scope.class
(union_specifier) @scope.class
(function_definition) @scope.function
(compound_statement) @scope.block
(if_statement) @scope.block
(for_statement) @scope.block
(while_statement) @scope.block
(do_statement) @scope.block
(switch_statement) @scope.block
(case_statement) @scope.block
;; Declarations struct (named)
(struct_specifier
name: (type_identifier) @declaration.name
body: (field_declaration_list)) @declaration.struct
;; Declarations struct (typedef struct { ... } Name)
(type_definition
type: (struct_specifier
body: (field_declaration_list))
declarator: (type_identifier) @declaration.name) @declaration.struct
;; Declarations union (named)
(union_specifier
name: (type_identifier) @declaration.name
body: (field_declaration_list)) @declaration.union
;; Declarations union (typedef union { ... } Name)
(type_definition
type: (union_specifier
body: (field_declaration_list))
declarator: (type_identifier) @declaration.name) @declaration.union
;; Declarations enum
(enum_specifier
name: (type_identifier) @declaration.name) @declaration.enum
;; Declarations function definition
(function_definition
declarator: (function_declarator
declarator: (identifier) @declaration.name)) @declaration.function
;; Declarations function definition with pointer return
(function_definition
declarator: (pointer_declarator
declarator: (function_declarator
declarator: (identifier) @declaration.name))) @declaration.function
;; Declarations function declaration (prototype)
;; Note: Both prototypes and definitions are captured as @declaration.function.
;; This may produce duplicate Function nodes in the knowledge graph when a
;; function is declared in a header and defined in a .c file. CALLS edges
;; resolve correctly through scope-based wildcard import chains; the
;; duplication is a graph-quality concern only (no false edges).
(declaration
declarator: (function_declarator
declarator: (identifier) @declaration.name)) @declaration.function
;; Declarations function declaration with pointer return (prototype)
(declaration
declarator: (pointer_declarator
declarator: (function_declarator
declarator: (identifier) @declaration.name))) @declaration.function
;; Declarations typedef
(type_definition
declarator: (type_identifier) @declaration.name) @declaration.typedef
;; Declarations typedef for function pointers: typedef void (*callback)(int, int)
(type_definition
declarator: (function_declarator
declarator: (parenthesized_declarator
(pointer_declarator
declarator: (type_identifier) @declaration.name)))) @declaration.typedef
;; Declarations struct fields
(field_declaration
declarator: (field_identifier) @declaration.name) @declaration.field
;; Declarations struct fields (pointer)
(field_declaration
declarator: (pointer_declarator
declarator: (field_identifier) @declaration.name)) @declaration.field
;; Declarations variables (with initializer)
(declaration
declarator: (init_declarator
declarator: (identifier) @declaration.name)) @declaration.variable
;; Declarations macro definitions
(preproc_def
name: (identifier) @declaration.name) @declaration.macro
(preproc_function_def
name: (identifier) @declaration.name) @declaration.macro
;; Declarations enum constants
(enumerator
name: (identifier) @declaration.name) @declaration.const
;; Imports
(preproc_include) @import.statement
;; Type bindings parameter annotations
(parameter_declaration
type: (_) @type-binding.type
declarator: (identifier) @type-binding.name) @type-binding.parameter
;; Type bindings variable with type (init_declarator)
(declaration
type: (_) @type-binding.type
declarator: (init_declarator
declarator: (identifier) @type-binding.name)) @type-binding.assignment
;; References free calls
;; Note: This also captures calls through function pointer variables (e.g. fp(x))
;; since tree-sitter-c produces structurally identical AST nodes for both direct
;; function calls and function-pointer-variable calls. A type-based guard to
;; distinguish variable-calls from function-calls is not implemented this is a
;; known architectural trade-off shared with the Go resolver. The uniqueness
;; constraint in pickUniqueGlobalCallable limits false edge exposure.
(call_expression
function: (identifier) @reference.name) @reference.call.free
;; References member calls via pointer (ptr->func())
(call_expression
function: (field_expression
argument: (_) @reference.receiver
field: (field_identifier) @reference.name)) @reference.call.member
;; References field reads
(field_expression
argument: (_) @reference.receiver
field: (field_identifier) @reference.name) @reference.read
;; References field writes (assignment)
(assignment_expression
left: (field_expression
argument: (_) @reference.receiver
field: (field_identifier) @reference.name)) @reference.write
`;
let _parser: Parser | null = null;
let _query: Parser.Query | null = null;
export function getCParser(): Parser {
if (_parser === null) {
_parser = new Parser();
_parser.setLanguage(C as Parameters<Parser['setLanguage']>[0]);
}
return _parser;
}
export function getCScopeQuery(): Parser.Query {
if (_query === null) {
_query = new Parser.Query(C as Parameters<Parser['setLanguage']>[0], C_SCOPE_QUERY);
}
return _query;
}

View file

@ -0,0 +1,73 @@
import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared';
import { SupportedLanguages } from 'gitnexus-shared';
import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js';
import { populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js';
import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js';
import { cProvider } from '../c-cpp.js';
import { cArityCompatibility, cMergeBindings, resolveCImportTarget } from './index.js';
import { scanHeaderFiles } from './header-scan.js';
import { expandCWildcardNames, isStaticName, clearStaticNames } from './static-linkage.js';
/**
* C `ScopeResolver` registered in `SCOPE_RESOLVERS` and consumed by
* the generic `runScopeResolution` orchestrator (RFC #909 Ring 3).
*
* C is a structurally simple language for scope resolution:
* - No classes (structs are value types, no method dispatch)
* - No inheritance (no MRO needed beyond the shared first-wins default)
* - No overloading (arity check is simple: variadic detection only)
* - `#include` is wildcard import (all symbols from header are visible)
* - `static` functions are file-local (not exported)
*/
export const cScopeResolver: ScopeResolver = {
language: SupportedLanguages.C,
languageProvider: cProvider,
importEdgeReason: 'c-scope: include',
loadResolutionConfig: (repoPath: string) => {
// Clear stale static-linkage data from any previous invocation to
// prevent cross-repo contamination in server-mode scenarios.
clearStaticNames();
return scanHeaderFiles(repoPath);
},
resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig) => {
// Augment allFilePaths with .h files discovered via loadResolutionConfig
// since the phase only passes .c files to the C resolver but #include
// targets .h files classified as C++ in language detection.
const headerPaths = resolutionConfig as ReadonlySet<string> | undefined;
if (headerPaths !== undefined && headerPaths.size > 0) {
const augmented = new Set(allFilePaths);
for (const h of headerPaths) augmented.add(h);
return resolveCImportTarget(targetRaw, fromFile, augmented);
}
return resolveCImportTarget(targetRaw, fromFile, allFilePaths);
},
expandsWildcardTo: (targetModuleScope, parsedFiles) =>
expandCWildcardNames(targetModuleScope, parsedFiles),
mergeBindings: (existing, incoming, scopeId) => cMergeBindings(existing, incoming, scopeId),
arityCompatibility: (callsite, def) => cArityCompatibility(def, callsite),
buildMro: (graph, parsedFiles, nodeLookup) =>
buildMro(graph, parsedFiles, nodeLookup, defaultLinearize),
populateOwners: (parsed: ParsedFile) => populateClassOwnedMembers(parsed),
isSuperReceiver: () => false,
// C is statically typed — disable field fallback heuristic
fieldFallbackOnMethodLookup: false,
// C has no method return types to propagate
propagatesReturnTypesAcrossImports: false,
// C #include brings in all symbols — enable global free call fallback
allowGlobalFreeCallFallback: true,
// C `static` functions have file-local (translation-unit) linkage —
// exclude them from global free-call fallback cross-file resolution.
isFileLocalDef: (def: SymbolDefinition) => {
const simple = def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? '';
return isStaticName(def.filePath, simple);
},
};

View file

@ -0,0 +1,38 @@
import type {
CaptureMatch,
ParsedImport,
Scope,
ScopeId,
ScopeTree,
TypeRef,
} from 'gitnexus-shared';
/**
* C binding scope: always use default auto-hoist (null).
* C has no self/receiver bindings that need special scoping.
*/
export function cBindingScopeFor(
_decl: CaptureMatch,
_innermost: Scope,
_tree: ScopeTree,
): ScopeId | null {
return null;
}
/**
* C import owning scope: always use default (null).
*/
export function cImportOwningScope(
_imp: ParsedImport,
_innermost: Scope,
_tree: ScopeTree,
): ScopeId | null {
return null;
}
/**
* C receiver binding: always null. C has no methods or receivers.
*/
export function cReceiverBinding(_functionScope: Scope): TypeRef | null {
return null;
}

View file

@ -0,0 +1,64 @@
import type { ParsedFile, ScopeId, SymbolDefinition } from 'gitnexus-shared';
/**
* Per-file set of function names declared with `static` storage class.
* Populated during `emitCScopeCaptures` and consumed by `expandCWildcardNames`
* to exclude file-local symbols from cross-file wildcard import visibility.
*
* NOTE: module-level state, single-process-single-repo use only.
* For server-mode or multi-repo-in-one-process use cases, call
* `clearStaticNames()` at the start of each resolution pass to avoid
* stale static-linkage data from a previous invocation.
*
* Key: filePath, Value: Set of static function names.
*/
const staticNames = new Map<string, Set<string>>();
/** Record a symbol name as `static` (file-local linkage) for the given file. */
export function markStaticName(filePath: string, name: string): void {
let names = staticNames.get(filePath);
if (names === undefined) {
names = new Set<string>();
staticNames.set(filePath, names);
}
names.add(name);
}
/** Check whether a symbol name has `static` linkage in the given file. */
export function isStaticName(filePath: string, name: string): boolean {
return staticNames.get(filePath)?.has(name) ?? false;
}
/** Clear tracked static names (for testing). */
export function clearStaticNames(): void {
staticNames.clear();
}
/**
* Return the names visible through a C wildcard import (`#include`).
* All module-scope defs from the target file are visible EXCEPT those
* declared with `static` storage class (file-local linkage in C).
*/
export function expandCWildcardNames(
targetModuleScope: ScopeId,
parsedFiles: readonly ParsedFile[],
): readonly string[] {
const target = parsedFiles.find((p) => p.moduleScope === targetModuleScope);
if (target === undefined) return [];
const seen = new Set<string>();
const names: string[] = [];
for (const def of target.localDefs) {
const name = simpleName(def);
if (name === '') continue;
if (isStaticName(target.filePath, name)) continue;
if (seen.has(name)) continue;
seen.add(name);
names.push(name);
}
return names;
}
function simpleName(def: SymbolDefinition): string {
return def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? '';
}

View file

@ -24,6 +24,7 @@ import { synthesizeCsharpReceiverBinding } from './receiver-binding.js';
import { getCsharpParser, getCsharpScopeQuery } from './query.js';
import { recordCacheHit, recordCacheMiss } from './cache-stats.js';
import { getTreeSitterBufferSize } from '../../constants.js';
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
/** Declaration anchors that carry function-like arity metadata. */
const FUNCTION_DECL_TAGS = [
@ -86,7 +87,7 @@ export function emitCsharpScopeCaptures(
// the LanguageProvider contract layer; cast here at the use site.
let tree = cachedTree as ReturnType<ReturnType<typeof getCsharpParser>['parse']> | undefined;
if (tree === undefined) {
tree = getCsharpParser().parse(sourceText, undefined, {
tree = parseSourceSafe(getCsharpParser(), sourceText, undefined, {
bufferSize: getTreeSitterBufferSize(sourceText),
});
recordCacheMiss();

View file

@ -36,6 +36,7 @@ import type { BindingRef, ParsedFile, Scope, ScopeId, SymbolDefinition } from 'g
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
import { getCsharpParser } from './query.js';
import { getTreeSitterBufferSize } from '../../constants.js';
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
interface CsharpFileStructure {
/** Declared namespace names in file source order. Empty array means
@ -56,7 +57,7 @@ function extractFileStructure(content: string, cachedTree: unknown): CsharpFileS
type CsharpTree = ReturnType<ReturnType<typeof getCsharpParser>['parse']>;
const tree =
(cachedTree as CsharpTree | undefined) ??
getCsharpParser().parse(content, undefined, {
parseSourceSafe(getCsharpParser(), content, undefined, {
bufferSize: getTreeSitterBufferSize(content),
});
const namespaces: string[] = [];
@ -359,7 +360,7 @@ export function populateCsharpNamespaceSiblings(
const q = def.qualifiedName ?? '';
const key = q.includes('.') ? q.slice(q.lastIndexOf('.') + 1) : q;
if (key === '') continue;
const arr = defsByName.get(key) ?? [];
const arr = [...(defsByName.get(key) ?? [])];
arr.push(def);
defsByName.set(key, arr);
}

View file

@ -12,6 +12,7 @@ import { splitGoImportStatement } from './import-decomposer.js';
import { synthesizeGoReceiverBinding } from './receiver-binding.js';
import { synthesizeGoTypeBindings } from './type-binding.js';
import { getTreeSitterBufferSize } from '../../constants.js';
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
export function emitGoScopeCaptures(
sourceText: string,
@ -20,7 +21,7 @@ export function emitGoScopeCaptures(
): readonly CaptureMatch[] {
let tree = cachedTree as ReturnType<ReturnType<typeof getGoParser>['parse']> | undefined;
if (tree === undefined) {
tree = getGoParser().parse(sourceText, undefined, {
tree = parseSourceSafe(getGoParser(), sourceText, undefined, {
bufferSize: getTreeSitterBufferSize(sourceText),
});
recordGoCacheMiss();

View file

@ -2,6 +2,7 @@ import type { ParsedFile, Scope, TypeRef } from 'gitnexus-shared';
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
import { getGoParser } from './query.js';
import { getTreeSitterBufferSize } from '../../constants.js';
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
export function populateGoRangeBindings(
parsedFiles: readonly ParsedFile[],
@ -20,7 +21,7 @@ export function populateGoRangeBindings(
const cachedTree = ctx.treeCache?.get(parsed.filePath);
const tree =
(cachedTree as ReturnType<typeof parser.parse> | undefined) ??
parser.parse(sourceText, undefined, {
parseSourceSafe(parser, sourceText, undefined, {
bufferSize: getTreeSitterBufferSize(sourceText),
});
const moduleScope = parsed.scopes.find((s) => s.kind === 'Module');

View file

@ -27,6 +27,17 @@ import { javaMethodConfig } from '../method-extractors/configs/jvm.js';
import { createVariableExtractor } from '../variable-extractors/generic.js';
import { javaVariableConfig } from '../variable-extractors/configs/jvm.js';
import { createHeritageExtractor } from '../heritage-extractors/generic.js';
import {
emitJavaScopeCaptures,
interpretJavaImport,
interpretJavaTypeBinding,
javaBindingScopeFor,
javaImportOwningScope,
javaMergeBindings,
javaReceiverBinding,
javaArityCompatibility,
resolveJavaImportTarget,
} from './java/index.js';
export const javaProvider = defineLanguage({
id: SupportedLanguages.Java,
@ -65,4 +76,15 @@ export const javaProvider = defineLanguage({
variableExtractor: createVariableExtractor(javaVariableConfig),
classExtractor: createClassExtractor(javaClassConfig),
heritageExtractor: createHeritageExtractor(SupportedLanguages.Java),
// ── RFC #909 Ring 3: scope-based resolution hooks ──
emitScopeCaptures: emitJavaScopeCaptures,
interpretImport: interpretJavaImport,
interpretTypeBinding: interpretJavaTypeBinding,
bindingScopeFor: javaBindingScopeFor,
importOwningScope: javaImportOwningScope,
mergeBindings: (_scope, bindings) => javaMergeBindings(bindings),
receiverBinding: javaReceiverBinding,
arityCompatibility: javaArityCompatibility,
resolveImportTarget: resolveJavaImportTarget,
});

View file

@ -0,0 +1,49 @@
/**
* Extract Java arity metadata from a method-like tree-sitter node
* `method_declaration` or `constructor_declaration`.
*
* Reuses `javaMethodConfig.extractParameters` so scope-extracted defs
* carry the same arity semantics as the legacy parse-worker path:
* - varargs (`...`) collapses `parameterCount` to `undefined`
* - `parameterTypes` collects declared type names; a literal
* `'varargs'` marker is appended for variadic methods so
* `javaArityCompatibility` can detect them.
*/
import type { SyntaxNode } from '../../utils/ast-helpers.js';
import { javaMethodConfig } from '../../method-extractors/configs/jvm.js';
export interface JavaArityMetadata {
readonly parameterCount: number | undefined;
readonly requiredParameterCount: number | undefined;
readonly parameterTypes: readonly string[] | undefined;
}
export function computeJavaArityMetadata(fnNode: SyntaxNode): JavaArityMetadata {
const params = javaMethodConfig.extractParameters?.(fnNode) ?? [];
let hasVariadic = false;
const types: string[] = [];
for (const p of params) {
if (p.isVariadic) hasVariadic = true;
if (p.type !== null) types.push(p.type);
}
if (hasVariadic) types.push('varargs');
const total = params.length;
// For varargs methods, `parameterCount` (max) is unknown — any number of
// trailing arguments is valid. But the fixed-prefix parameters (everything
// before the variadic `...` param) are still required, so we preserve that
// count in `requiredParameterCount` so `javaArityCompatibility` can reject
// calls that undersupply the fixed prefix (e.g. `f(int x, String... args)`
// called with 0 args).
const fixedCount = params.filter((p) => !p.isVariadic).length;
const parameterCount = hasVariadic ? undefined : total;
const requiredParameterCount = hasVariadic ? fixedCount : total;
return {
parameterCount,
requiredParameterCount,
parameterTypes: types.length > 0 ? types : undefined,
};
}

View file

@ -0,0 +1,31 @@
/**
* Java arity check, accommodating varargs (`...`).
*
* Verdicts:
* - `'compatible'` argCount matches parameterCount, OR varargs present.
* - `'incompatible'` argCount mismatches with no varargs.
* - `'unknown'` metadata absent / incomplete.
*/
import type { Callsite, SymbolDefinition } from 'gitnexus-shared';
export function javaArityCompatibility(
def: SymbolDefinition,
callsite: Callsite,
): 'compatible' | 'unknown' | 'incompatible' {
const max = def.parameterCount;
const min = def.requiredParameterCount;
if (max === undefined && min === undefined) return 'unknown';
const argCount = callsite.arity;
if (!Number.isFinite(argCount) || argCount < 0) return 'unknown';
const hasVarArgs =
def.parameterTypes !== undefined &&
def.parameterTypes.some((t) => t === 'varargs' || t.includes('...'));
if (min !== undefined && argCount < min) return 'incompatible';
if (max !== undefined && argCount > max && !hasVarArgs) return 'incompatible';
return 'compatible';
}

View file

@ -0,0 +1,30 @@
/**
* Dev-mode counters for the cross-phase scope-captures parse cache
* (Java mirror of `languages/csharp/cache-stats.ts`).
*
* Gated by `PROF_SCOPE_RESOLUTION=1`. Production builds fold every
* increment into dead code via the module-level `PROF` constant, so
* the hot path in `captures.ts` stays branch-free.
*/
const PROF = process.env.PROF_SCOPE_RESOLUTION === '1';
let CACHE_HITS = 0;
let CACHE_MISSES = 0;
export function recordCacheHit(): void {
if (PROF) CACHE_HITS++;
}
export function recordCacheMiss(): void {
if (PROF) CACHE_MISSES++;
}
export function getJavaCaptureCacheStats(): { hits: number; misses: number } {
return { hits: CACHE_HITS, misses: CACHE_MISSES };
}
export function resetJavaCaptureCacheStats(): void {
CACHE_HITS = 0;
CACHE_MISSES = 0;
}

View file

@ -0,0 +1,235 @@
/**
* `emitScopeCaptures` for Java.
*
* Drives the Java scope query against tree-sitter-java and groups raw
* matches into `CaptureMatch[]` for the central extractor. Layers:
*
* 1. **Decomposed import declarations** each `import_declaration`
* is re-emitted with `@import.kind/source/name` markers.
* 2. **Receiver binding synthesis** `this`/`super` type-bindings
* on instance methods.
* 3. **Arity metadata** on method/constructor declarations.
* 4. **Reference arity** on call sites.
*
* Pure given the input source text. No I/O, no globals consulted.
*/
import type { Capture, CaptureMatch } from 'gitnexus-shared';
import { findNodeAtRange, nodeToCapture, syntheticCapture } from '../../utils/ast-helpers.js';
import { splitImportDeclaration } from './import-decomposer.js';
import { computeJavaArityMetadata } from './arity-metadata.js';
import { synthesizeJavaReceiverBinding } from './receiver-binding.js';
import { getJavaParser, getJavaScopeQuery } from './query.js';
import { recordCacheHit, recordCacheMiss } from './cache-stats.js';
import { getTreeSitterBufferSize } from '../../constants.js';
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
/** Declaration anchors that carry function-like arity metadata. */
const FUNCTION_DECL_TAGS = ['@declaration.method', '@declaration.constructor'] as const;
/** tree-sitter-java node types that the method extractor accepts. */
const FUNCTION_NODE_TYPES = ['method_declaration', 'constructor_declaration'] as const;
/** Suppress read.member emissions when the field_access is already
* covered by a method_invocation (object of a call) or an
* assignment_expression (write target). */
function shouldEmitReadMember(memberNode: SyntaxNode): boolean {
const parent = memberNode.parent;
if (parent === null) return true;
switch (parent.type) {
case 'method_invocation':
// Don't emit read.member when the field_access is the object of a method_invocation
// (the method call already handles this relationship)
return parent.childForFieldName('object')?.id !== memberNode.id;
case 'assignment_expression':
return parent.childForFieldName('left')?.id !== memberNode.id;
default:
return true;
}
}
export function emitJavaScopeCaptures(
sourceText: string,
_filePath: string,
cachedTree?: unknown,
): readonly CaptureMatch[] {
let tree = cachedTree as ReturnType<ReturnType<typeof getJavaParser>['parse']> | undefined;
if (tree === undefined) {
tree = parseSourceSafe(getJavaParser(), sourceText, undefined, {
bufferSize: getTreeSitterBufferSize(sourceText),
});
recordCacheMiss();
} else {
recordCacheHit();
}
const rawMatches = getJavaScopeQuery().matches(tree.rootNode);
const out: CaptureMatch[] = [];
for (const m of rawMatches) {
const grouped: Record<string, Capture> = {};
for (const c of m.captures) {
const tag = '@' + c.name;
grouped[tag] = nodeToCapture(tag, c.node);
}
if (Object.keys(grouped).length === 0) continue;
// Decompose each `import_declaration`.
if (grouped['@import.statement'] !== undefined) {
const stmtCapture = grouped['@import.statement'];
const stmtNode = findNodeAtRange(tree.rootNode, stmtCapture.range, 'import_declaration');
if (stmtNode !== null) {
const decomposed = splitImportDeclaration(stmtNode);
if (decomposed !== null) {
out.push(decomposed);
continue;
}
}
out.push(grouped);
continue;
}
// Skip free-call matches that are actually member calls. The query
// matches ALL method_invocations as @reference.call.free (without
// negation) because tree-sitter-java's query engine drops !object
// patterns when a positive object: pattern exists for the same node
// type. Filter here: if the match has @reference.call.free but also
// has @reference.receiver, it's a member call — skip the free match
// (the separate @reference.call.member match covers it).
if (
grouped['@reference.call.free'] !== undefined &&
grouped['@reference.receiver'] !== undefined
) {
continue;
}
// Filter read.member when it's a child of method_invocation or assignment.
if (grouped['@reference.read.member'] !== undefined) {
const anchor = grouped['@reference.read.member'];
const memberNode = findNodeAtRange(tree.rootNode, anchor.range, 'field_access');
if (memberNode === null || !shouldEmitReadMember(memberNode)) {
continue;
}
}
// Synthesize `this` / `super` receiver type-bindings on every
// instance method-like.
if (grouped['@scope.function'] !== undefined) {
out.push(grouped);
const anchor = grouped['@scope.function']!;
const fnNode = findFunctionNode(tree.rootNode, anchor.range);
if (fnNode !== null) {
for (const synth of synthesizeJavaReceiverBinding(fnNode)) {
out.push(synth);
}
}
continue;
}
// Synthesize arity metadata on function-like declarations.
const declTag = FUNCTION_DECL_TAGS.find((t) => grouped[t] !== undefined);
if (declTag !== undefined) {
const anchor = grouped[declTag]!;
const fnNode = findFunctionNode(tree.rootNode, anchor.range);
if (fnNode !== null) {
const arity = computeJavaArityMetadata(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) {
grouped['@declaration.parameter-types'] = syntheticCapture(
'@declaration.parameter-types',
fnNode,
JSON.stringify(arity.parameterTypes),
);
}
}
}
// Synthesize `@reference.arity` on every callsite.
const callTag = (
['@reference.call.free', '@reference.call.member', '@reference.call.constructor'] as const
).find((t) => grouped[t] !== undefined);
if (callTag !== undefined && grouped['@reference.arity'] === undefined) {
const anchor = grouped[callTag]!;
const callNode =
findNodeAtRange(tree.rootNode, anchor.range, 'method_invocation') ??
findNodeAtRange(tree.rootNode, anchor.range, 'object_creation_expression');
if (callNode !== null) {
const argList = callNode.childForFieldName('arguments');
const args =
argList === null
? []
: argList.namedChildren.filter((c) => c !== null && c.type !== 'comment');
grouped['@reference.arity'] = syntheticCapture(
'@reference.arity',
callNode,
String(args.length),
);
const argTypes = args.map((arg) => inferArgType(arg!));
grouped['@reference.parameter-types'] = syntheticCapture(
'@reference.parameter-types',
callNode,
JSON.stringify(argTypes),
);
}
}
out.push(grouped);
}
return out;
}
type SyntaxNode = ReturnType<ReturnType<typeof getJavaParser>['parse']>['rootNode'];
/** Infer a Java argument's static type from literal patterns. */
function inferArgType(argNode: SyntaxNode): string {
switch (argNode.type) {
case 'decimal_integer_literal':
case 'hex_integer_literal':
case 'octal_integer_literal':
case 'binary_integer_literal':
return 'int';
case 'decimal_floating_point_literal':
case 'hex_floating_point_literal':
return 'double';
case 'string_literal':
return 'String';
case 'character_literal':
return 'char';
case 'true':
case 'false':
return 'boolean';
case 'null_literal':
return 'null';
case 'object_creation_expression': {
const typeNode = argNode.childForFieldName('type');
return typeNode?.text ?? '';
}
default:
return '';
}
}
/** Find the first Java function-like node at the given range. */
function findFunctionNode(rootNode: SyntaxNode, range: Capture['range']): SyntaxNode | null {
for (const nodeType of FUNCTION_NODE_TYPES) {
const n = findNodeAtRange(rootNode, range, nodeType);
if (n !== null) return n as SyntaxNode;
}
return null;
}

View file

@ -0,0 +1,104 @@
/**
* Decompose a Java `import_declaration` into a `CaptureMatch` carrying
* the synthesized markers `@import.kind` / `@import.source` /
* `@import.name` that `interpretJavaImport` consumes.
*
* Unlike C#'s using-directive decomposer, Java has four import forms:
*
* import com.example.User; named
* import com.example.*; wildcard
* import static com.example.Utils.format; static
* import static com.example.Utils.*; static-wildcard
*
* Each produces exactly one import. The decomposer inspects the raw
* source text and tree-sitter children to determine the flavor.
*/
import type { Capture, CaptureMatch } from 'gitnexus-shared';
import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
type ImportKind = 'named' | 'wildcard' | 'static' | 'static-wildcard';
interface ImportSpec {
readonly kind: ImportKind;
/** Full dotted path: `com.example.User`. */
readonly source: string;
/** Local binding name last path segment for named/static,
* `'*'` for wildcard/static-wildcard. */
readonly name: string;
/** Node to anchor the synthesized captures (range-wise). */
readonly atNode: SyntaxNode;
}
export function splitImportDeclaration(stmtNode: SyntaxNode): CaptureMatch | null {
if (stmtNode.type !== 'import_declaration') return null;
const spec = parseImportDeclaration(stmtNode);
if (spec === null) return null;
return buildImportMatch(stmtNode, spec);
}
function parseImportDeclaration(node: SyntaxNode): ImportSpec | null {
// Detect `static` by checking for an anonymous `static` token child.
let isStatic = false;
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child !== null && child.type === 'static') {
isStatic = true;
break;
}
}
// Detect wildcard by checking for `asterisk` named child.
let isWildcard = false;
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child !== null && child.type === 'asterisk') {
isWildcard = true;
break;
}
}
// Find the scoped_identifier (or identifier for single-segment imports).
let pathNode: SyntaxNode | null = null;
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child !== null && (child.type === 'scoped_identifier' || child.type === 'identifier')) {
pathNode = child;
break;
}
}
if (pathNode === null) return null;
const fullPath = pathNode.text;
if (fullPath === '') return null;
if (isStatic && isWildcard) {
// `import static com.example.Utils.*;`
return { kind: 'static-wildcard', source: fullPath, name: '*', atNode: node };
}
if (isStatic) {
// `import static com.example.Utils.format;`
const lastDot = fullPath.lastIndexOf('.');
const name = lastDot >= 0 ? fullPath.slice(lastDot + 1) : fullPath;
return { kind: 'static', source: fullPath, name, atNode: node };
}
if (isWildcard) {
// `import com.example.*;`
return { kind: 'wildcard', source: fullPath, name: '*', atNode: node };
}
// `import com.example.User;`
const lastDot = fullPath.lastIndexOf('.');
const name = lastDot >= 0 ? fullPath.slice(lastDot + 1) : fullPath;
return { kind: 'named', source: fullPath, name, atNode: node };
}
function buildImportMatch(stmtNode: SyntaxNode, spec: ImportSpec): CaptureMatch {
const m: Record<string, Capture> = {
'@import.statement': nodeToCapture('@import.statement', stmtNode),
'@import.kind': syntheticCapture('@import.kind', spec.atNode, spec.kind),
'@import.source': syntheticCapture('@import.source', spec.atNode, spec.source),
'@import.name': syntheticCapture('@import.name', spec.atNode, spec.name),
};
return m;
}

View file

@ -0,0 +1,108 @@
/**
* Adapter from `(ParsedImport, WorkspaceIndex)` concrete file path.
*
* Converts Java package paths (dots slashes) and tries:
* 1. Exact file match: `com/example/User.java`
* 2. Suffix match for nested layouts
* 3. Directory match (wildcard imports)
* 4. Progressive prefix stripping for non-standard layouts
*
* Returns `null` for unresolvable / JDK imports.
*/
import type { ParsedImport, WorkspaceIndex } from 'gitnexus-shared';
export interface JavaResolveContext {
readonly fromFile: string;
readonly allFilePaths: ReadonlySet<string>;
}
export function resolveJavaImportTarget(
parsedImport: ParsedImport,
workspaceIndex: WorkspaceIndex,
): string | null {
const ctx = workspaceIndex as JavaResolveContext | undefined;
if (
ctx === undefined ||
typeof (ctx as { fromFile?: unknown }).fromFile !== 'string' ||
!((ctx as { allFilePaths?: unknown }).allFilePaths instanceof Set)
) {
return null;
}
if (parsedImport.kind === 'dynamic-unresolved') return null;
if (parsedImport.targetRaw === null || parsedImport.targetRaw === '') return null;
// Strip trailing `.*` for wildcard imports: `com.example.*` → `com.example`
let target = parsedImport.targetRaw;
if (target.endsWith('.*')) {
target = target.slice(0, -2);
}
// Package path: `com.example.User` → `com/example/User`
const pathLike = target.replace(/\./g, '/');
const suffix = `/${pathLike}`;
let exactFile: string | null = null;
let suffixFile: string | null = null;
let directoryChild: string | null = null;
const dirPrefix = `${pathLike}/`;
const suffixDirPrefix = `/${dirPrefix}`;
for (const raw of ctx.allFilePaths) {
const f = raw.replace(/\\/g, '/');
if (!f.endsWith('.java')) continue;
if (f === `${pathLike}.java`) {
exactFile = raw;
break;
}
if (suffixFile === null && f.endsWith(`${suffix}.java`)) {
suffixFile = raw;
}
if (directoryChild === null) {
const atRoot = f.startsWith(dirPrefix);
const atNested = f.includes(suffixDirPrefix);
if (atRoot || atNested) {
const idx = atRoot ? 0 : f.indexOf(suffixDirPrefix) + 1;
const after = f.slice(idx + dirPrefix.length);
if (after.length > 0 && !after.includes('/')) {
directoryChild = raw;
}
}
}
}
if (exactFile !== null) return exactFile;
if (suffixFile !== null) return suffixFile;
if (directoryChild !== null) return directoryChild;
// Progressive prefix stripping — handles `import com.example.User;`
// in a repo laid out `User.java` (no `com/example/` prefix).
const segments = pathLike.split('/').filter(Boolean);
for (let skip = 1; skip < segments.length; skip++) {
const tail = segments.slice(skip).join('/');
if (tail === '') continue;
const tailFile = `${tail}.java`;
const tailSuffix = `/${tailFile}`;
const tailDir = `${tail}/`;
const tailSuffixDir = `/${tailDir}`;
let tailDirectChild: string | null = null;
for (const raw of ctx.allFilePaths) {
const f = raw.replace(/\\/g, '/');
if (!f.endsWith('.java')) continue;
if (f === tailFile) return raw;
if (f.endsWith(tailSuffix)) return raw;
if (tailDirectChild === null) {
const atRoot = f.startsWith(tailDir);
const atNested = f.includes(tailSuffixDir);
if (atRoot || atNested) {
const idx = atRoot ? 0 : f.indexOf(tailSuffixDir) + 1;
const after = f.slice(idx + tailDir.length);
if (after.length > 0 && !after.includes('/')) tailDirectChild = raw;
}
}
}
if (tailDirectChild !== null) return tailDirectChild;
}
return null;
}

View file

@ -0,0 +1,30 @@
/**
* Java scope-resolution hooks (RFC #909 Ring 3).
*
* Public API barrel. Consumers should import from this file rather than
* the individual modules.
*
* Module layout:
*
* - `query.ts` tree-sitter query + lazy parser/query singletons
* - `captures.ts` `emitJavaScopeCaptures` orchestrator
* - `import-decomposer.ts` each `import` ParsedImport-shaped captures
* - `interpret.ts` capture-match `ParsedImport` / `ParsedTypeBinding`
* - `simple-hooks.ts` small hooks made explicit
* - `receiver-binding.ts` synthesize `this`/`super` type-bindings on
* instance-method entry
* - `merge-bindings.ts` Java import precedence
* - `arity.ts` Java arity compatibility (varargs)
* - `arity-metadata.ts` synthesize arity metadata from declarations
* - `import-target.ts` `(ParsedImport, WorkspaceIndex) → file path` adapter
* - `scope-resolver.ts` `ScopeResolver` registered in `SCOPE_RESOLVERS`
* - `cache-stats.ts` PROF_SCOPE_RESOLUTION cache hit/miss counters
*/
export { emitJavaScopeCaptures } from './captures.js';
export { getJavaCaptureCacheStats, resetJavaCaptureCacheStats } from './cache-stats.js';
export { interpretJavaImport, interpretJavaTypeBinding } from './interpret.js';
export { javaMergeBindings } from './merge-bindings.js';
export { javaArityCompatibility } from './arity.js';
export { resolveJavaImportTarget, type JavaResolveContext } from './import-target.js';
export { javaBindingScopeFor, javaImportOwningScope, javaReceiverBinding } from './simple-hooks.js';

View file

@ -0,0 +1,141 @@
/**
* Capture-match semantic-shape interpreters for Java.
*
* - `interpretJavaImport` `ParsedImport`
* - `interpretJavaTypeBinding` `ParsedTypeBinding`
*
* Import matches arrive pre-decomposed by `emitJavaScopeCaptures`
* (one import per match, with synthesized `@import.kind/source/name`
* markers). Type-binding matches arrive from the raw query captures.
*/
import type { CaptureMatch, ParsedImport, ParsedTypeBinding, TypeRef } from 'gitnexus-shared';
// ─── interpretImport ──────────────────────────────────────────────────────
export function interpretJavaImport(captures: CaptureMatch): ParsedImport | null {
const kindCap = captures['@import.kind'];
const sourceCap = captures['@import.source'];
const nameCap = captures['@import.name'];
const kind = kindCap?.text;
if (kind === undefined || sourceCap === undefined) return null;
switch (kind) {
case 'named': {
// `import com.example.User;`
return {
kind: 'named',
localName: nameCap?.text ?? sourceCap.text.split('.').pop() ?? sourceCap.text,
importedName: sourceCap.text,
targetRaw: sourceCap.text,
};
}
case 'wildcard': {
// `import com.example.*;`
return {
kind: 'wildcard',
targetRaw: sourceCap.text + '.*',
};
}
case 'static': {
// `import static com.example.Utils.format;`
// The source contains the full path including the member name
// (e.g. `com.example.Utils.format`). For file resolution we need
// the class path (`com.example.Utils`), so strip the final member
// segment. The local binding name is the member itself.
const fullSource = sourceCap.text;
const lastDot = fullSource.lastIndexOf('.');
const classPath = lastDot >= 0 ? fullSource.slice(0, lastDot) : fullSource;
return {
kind: 'named',
localName: nameCap?.text ?? (lastDot >= 0 ? fullSource.slice(lastDot + 1) : fullSource),
importedName: fullSource,
targetRaw: classPath,
};
}
case 'static-wildcard': {
// `import static com.example.Utils.*;`
// The source is the class path (e.g. `com.example.Utils`).
// Resolution should target the class file, not a wildcard directory
// scan — `Utils.java` is the file that contains the static members.
return {
kind: 'wildcard',
targetRaw: sourceCap.text + '.*',
};
}
default:
return null;
}
}
// ─── interpretTypeBinding ─────────────────────────────────────────────────
export function interpretJavaTypeBinding(captures: CaptureMatch): ParsedTypeBinding | null {
const nameCap = captures['@type-binding.name'];
const typeCap = captures['@type-binding.type'];
if (nameCap === undefined || typeCap === undefined) return null;
// Strip qualifier first so that `com.example.BaseModel<T>` becomes
// `BaseModel<T>` before stripGeneric — the JVM-erasure fallback pattern
// requires an unqualified identifier at the start of the string.
const rawType = stripGeneric(stripQualifier(typeCap.text.trim()));
// Skip `var` — tree-sitter-java parses `var` as type_identifier with
// text "var". When used without a constructor initializer, there's no
// concrete type to bind.
if (rawType === 'var') return null;
let source: TypeRef['source'] = 'parameter-annotation';
if (captures['@type-binding.self'] !== undefined) source = 'self';
else if (captures['@type-binding.constructor'] !== undefined) source = 'constructor-inferred';
else if (captures['@type-binding.annotation'] !== undefined) source = 'annotation';
else if (captures['@type-binding.return'] !== undefined) source = 'return-annotation';
return { boundName: nameCap.text, rawTypeName: rawType, source };
}
/**
* Unwrap generic type parameters from Java types.
*
* Three tiers, checked in order:
* 1. Known single-arg collection wrappers extract the element type
* (`List<User>` `User`, `Optional<User>` `User`).
* 2. Known two-arg map/container types extract the value type
* (`Map<String, User>` `User`).
* 3. **Fallback (JVM type erasure):** any other generic type
* strip the generic parameters and keep the raw class name
* (`BaseModel<T>` `BaseModel`, `CustomList<Foo>` `CustomList`).
* This ensures receiver bindings (`this`/`super`) on classes with
* generic superclasses resolve to the correct class file.
*/
function stripGeneric(text: string): string {
// Single-type-argument containers — extract the element type.
const single = text.match(
/^(?:[A-Za-z_][A-Za-z0-9_.]*\.)?(?:List|ArrayList|LinkedList|Set|HashSet|TreeSet|SortedSet|LinkedHashSet|Collection|Iterable|Iterator|Optional|Stream|CompletableFuture|Future|Queue|Deque|ArrayDeque|PriorityQueue|Vector|Stack|Supplier|Consumer|Predicate|Function)<([^,<>]+)>$/,
);
if (single !== null) return single[1].trim();
// Two-type-argument map/container types — extract the value type (second arg).
const twoArg = text.match(
/^(?:[A-Za-z_][A-Za-z0-9_.]*\.)?(?:Map|HashMap|TreeMap|LinkedHashMap|ConcurrentHashMap|ConcurrentMap|SortedMap|NavigableMap|Hashtable|EnumMap|WeakHashMap|IdentityHashMap|BiFunction|BiConsumer|BiPredicate|Pair|Entry)<[^,<>]+,\s*([^,<>]+)>$/,
);
if (twoArg !== null) return twoArg[1].trim();
// Fallback: strip generic parameters from any unrecognized generic type.
// `BaseModel<T>` → `BaseModel`, `Builder<Self>` → `Builder`.
// This mirrors JVM type erasure — the raw class name is the resolvable symbol.
// The pattern matches up to the first `<` to handle nested generics safely
// (e.g. `BaseModel<List<String>>` → `BaseModel`).
const fallback = text.match(/^([A-Za-z_$][A-Za-z0-9_$]*)<.+>$/s);
if (fallback !== null) return fallback[1].trim();
return text;
}
/** `com.example.User` → `User`. */
function stripQualifier(text: string): string {
const lastDot = text.lastIndexOf('.');
if (lastDot === -1) return text;
return text.slice(lastDot + 1);
}

View file

@ -0,0 +1,44 @@
/**
* Java shadowing precedence for the `mergeBindings` hook.
*
* Tier ranking (lower wins):
* - 0: `local` class member, method, local variable, parameter
* - 1: `import` / `namespace` / `reexport` explicit imports
* - 2: `wildcard` wildcard imports (`import x.y.*`)
*
* Within a surviving tier: de-dup by DefId, last-write-wins.
*/
import type { BindingRef } from 'gitnexus-shared';
const TIER_LOCAL = 0;
const TIER_IMPORT = 1;
const TIER_WILDCARD = 2;
const TIER_UNKNOWN = 3;
function tierOf(b: BindingRef): number {
switch (b.origin) {
case 'local':
return TIER_LOCAL;
case 'reexport':
case 'import':
case 'namespace':
return TIER_IMPORT;
case 'wildcard':
return TIER_WILDCARD;
default:
return TIER_UNKNOWN;
}
}
export function javaMergeBindings(bindings: readonly BindingRef[]): readonly BindingRef[] {
if (bindings.length === 0) return bindings;
let bestTier = Number.POSITIVE_INFINITY;
for (const b of bindings) bestTier = Math.min(bestTier, tierOf(b));
const survivors = bindings.filter((b) => tierOf(b) === bestTier);
const seen = new Map<string, BindingRef>();
for (const b of survivors) seen.set(b.def.nodeId, b);
return [...seen.values()];
}

View file

@ -0,0 +1,197 @@
/**
* Tree-sitter query for Java scope captures (RFC §5.1).
*
* Captures the structural skeleton the generic scope-resolution
* pipeline consumes: scopes (module/class/function), declarations
* (class-likes, method-likes, fields, variables), imports (import
* declarations), type bindings (parameter annotations, variable
* annotations, constructor inference), and references (call sites,
* member writes/reads).
*
* Java specifics that shape this query:
*
* - Java uses `program` as the root node (not `compilation_unit`).
* - `import_declaration` nodes carry `scoped_identifier` children
* and optional `asterisk` for wildcard imports.
* - `static` imports are detected by an anonymous `static` token
* child within `import_declaration`.
* - `var` (Java 10+ local variable type inference) parses as a
* `type_identifier` with text `"var"`, not a dedicated node type.
* - Modifiers (`public`, `static`, etc.) are grouped under a
* `modifiers` named child with anonymous keyword tokens.
* - Superclass inheritance uses a `superclass:` field containing
* a `superclass` node wrapping a `type_identifier`.
*
* Exposes lazy `Parser` and `Query` singletons so callers don't pay
* tree-sitter init cost per file.
*/
import Parser from 'tree-sitter';
import Java from 'tree-sitter-java';
const JAVA_SCOPE_QUERY = `
;; Scopes
(program) @scope.module
(class_declaration) @scope.class
(interface_declaration) @scope.class
(enum_declaration) @scope.class
(record_declaration) @scope.class
(annotation_type_declaration) @scope.class
(method_declaration) @scope.function
(constructor_declaration) @scope.function
;; Declarations types
(class_declaration
name: (identifier) @declaration.name) @declaration.class
(interface_declaration
name: (identifier) @declaration.name) @declaration.interface
(enum_declaration
name: (identifier) @declaration.name) @declaration.enum
(record_declaration
name: (identifier) @declaration.name) @declaration.record
(annotation_type_declaration
name: (identifier) @declaration.name) @declaration.class
;; Declarations methods / constructors
(method_declaration
name: (identifier) @declaration.name) @declaration.method
(constructor_declaration
name: (identifier) @declaration.name) @declaration.constructor
;; Declarations fields
(field_declaration
declarator: (variable_declarator
name: (identifier) @declaration.name)) @declaration.variable
;; Declarations local variables
(local_variable_declaration
declarator: (variable_declarator
name: (identifier) @declaration.name)) @declaration.variable
;; Imports single anchor per import_declaration
(import_declaration) @import.statement
;; Type bindings parameter annotations: void f(User u)
(formal_parameter
type: (type_identifier) @type-binding.type
name: (identifier) @type-binding.name) @type-binding.parameter
(formal_parameter
type: (generic_type) @type-binding.type
name: (identifier) @type-binding.name) @type-binding.parameter
(formal_parameter
type: (scoped_type_identifier) @type-binding.type
name: (identifier) @type-binding.name) @type-binding.parameter
;; Type bindings local variable annotations: User u = new User();
(local_variable_declaration
type: (type_identifier) @type-binding.type
declarator: (variable_declarator
name: (identifier) @type-binding.name)) @type-binding.annotation
(local_variable_declaration
type: (generic_type) @type-binding.type
declarator: (variable_declarator
name: (identifier) @type-binding.name)) @type-binding.annotation
;; Type bindings var u = new User(); (Java 10+ local variable type inference)
;; tree-sitter-java parses \`var\` as a \`type_identifier\` with text "var".
;; The type-binding.constructor anchor fires when the rhs is an
;; object_creation_expression so interpretJavaTypeBinding can infer
;; the concrete type from the constructor call.
(local_variable_declaration
type: (type_identifier) @_var_type
declarator: (variable_declarator
name: (identifier) @type-binding.name
value: (object_creation_expression
type: (type_identifier) @type-binding.type))) @type-binding.constructor
;; Type bindings field declarations: private User user;
(field_declaration
type: (type_identifier) @type-binding.type
declarator: (variable_declarator
name: (identifier) @type-binding.name)) @type-binding.annotation
(field_declaration
type: (generic_type) @type-binding.type
declarator: (variable_declarator
name: (identifier) @type-binding.name)) @type-binding.annotation
;; Type bindings method return type: public User getUser() { }
(method_declaration
type: (type_identifier) @type-binding.type
name: (identifier) @type-binding.name) @type-binding.return
(method_declaration
type: (generic_type) @type-binding.type
name: (identifier) @type-binding.name) @type-binding.return
;; Type bindings enhanced for: for (User u : list)
(enhanced_for_statement
type: (type_identifier) @type-binding.type
name: (identifier) @type-binding.name) @type-binding.annotation
(enhanced_for_statement
type: (generic_type) @type-binding.type
name: (identifier) @type-binding.name) @type-binding.annotation
;; References all method calls: foo() and obj.method()
;; tree-sitter-java's query engine drops negation-based \`!object\`
;; patterns when a positive \`object:\` pattern exists for the same
;; node type, so we match all calls here and classify free vs
;; member in captures.ts based on the presence of @reference.receiver.
(method_invocation
object: (_) @reference.receiver
name: (identifier) @reference.name) @reference.call.member
(method_invocation
name: (identifier) @reference.name) @reference.call.free
;; References constructor calls: new User(...)
(object_creation_expression
type: (type_identifier) @reference.name) @reference.call.constructor
(object_creation_expression
type: (generic_type
(type_identifier) @reference.name)) @reference.call.constructor
(object_creation_expression
type: (scoped_type_identifier) @reference.call.constructor.qualified) @reference.call.constructor
;; References field/property writes: obj.name = "x"
(assignment_expression
left: (field_access
object: (_) @reference.receiver
field: (identifier) @reference.name)) @reference.write.member
;; References field/property reads: obj.name
(field_access
object: (_) @reference.receiver
field: (identifier) @reference.name) @reference.read.member
`;
let _parser: Parser | null = null;
let _query: Parser.Query | null = null;
export function getJavaParser(): Parser {
if (_parser === null) {
_parser = new Parser();
_parser.setLanguage(Java as Parameters<Parser['setLanguage']>[0]);
}
return _parser;
}
export function getJavaScopeQuery(): Parser.Query {
if (_query === null) {
_query = new Parser.Query(Java as Parameters<Parser['setLanguage']>[0], JAVA_SCOPE_QUERY);
}
return _query;
}

View file

@ -0,0 +1,103 @@
/**
* Synthesize `@type-binding.self` captures for Java instance methods
* one for `this` (always on non-static methods inside a type
* declaration) and optionally one for `super` (only on class methods
* when the enclosing class has a `superclass`).
*
* Mirrors `languages/csharp/receiver-binding.ts` in structure.
*/
import type { Capture, CaptureMatch } from 'gitnexus-shared';
import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
const TYPE_DECL_NODE_TYPES = new Set([
'class_declaration',
'interface_declaration',
'enum_declaration',
'record_declaration',
]);
const FUNCTION_NODE_TYPES = new Set(['method_declaration', 'constructor_declaration']);
/** Walk up to the enclosing type declaration. */
function findEnclosingTypeDeclaration(node: SyntaxNode): SyntaxNode | null {
let cur: SyntaxNode | null = node.parent;
while (cur !== null) {
if (TYPE_DECL_NODE_TYPES.has(cur.type)) return cur;
cur = cur.parent;
}
return null;
}
function typeName(typeNode: SyntaxNode): string | null {
return typeNode.childForFieldName('name')?.text ?? null;
}
/** First superclass text. tree-sitter-java uses a `superclass` field
* containing a `superclass` node wrapping a `type_identifier`. */
function firstSuperclassText(typeNode: SyntaxNode): string | null {
const superclass = typeNode.childForFieldName('superclass');
if (superclass === null) return null;
// The superclass node wraps the type_identifier
for (let i = 0; i < superclass.namedChildCount; i++) {
const child = superclass.namedChild(i);
if (child !== null && (child.type === 'type_identifier' || child.type === 'generic_type')) {
return child.text;
}
}
return null;
}
/** Check if a method has the `static` modifier. In tree-sitter-java,
* modifiers are grouped under a `modifiers` named child with anonymous
* keyword tokens. */
function isStaticMethod(fnNode: SyntaxNode): boolean {
for (let i = 0; i < fnNode.namedChildCount; i++) {
const child = fnNode.namedChild(i);
if (child !== null && child.type === 'modifiers') {
for (let j = 0; j < child.childCount; j++) {
const mod = child.child(j);
if (mod !== null && mod.text.trim() === 'static') return true;
}
}
}
return false;
}
export function synthesizeJavaReceiverBinding(fnNode: SyntaxNode): CaptureMatch[] {
if (!FUNCTION_NODE_TYPES.has(fnNode.type)) return [];
if (isStaticMethod(fnNode)) return [];
const enclosingType = findEnclosingTypeDeclaration(fnNode);
if (enclosingType === null) return [];
const enclosingName = typeName(enclosingType);
if (enclosingName === null) return [];
// Anchor to the method body so the synthesized captures are inside
// the function scope.
const anchorNode = fnNode.childForFieldName('body');
if (anchorNode === null) return [];
const out: CaptureMatch[] = [];
out.push(buildReceiverMatch(anchorNode, 'this', enclosingName));
// `super` applies only to class/record methods with an explicit superclass.
if (enclosingType.type === 'class_declaration' || enclosingType.type === 'record_declaration') {
const superText = firstSuperclassText(enclosingType);
if (superText !== null) {
out.push(buildReceiverMatch(anchorNode, 'super', superText));
}
}
return out;
}
function buildReceiverMatch(anchorNode: SyntaxNode, name: string, typeText: string): CaptureMatch {
const m: Record<string, Capture> = {
'@type-binding.self': nodeToCapture('@type-binding.self', anchorNode),
'@type-binding.name': syntheticCapture('@type-binding.name', anchorNode, name),
'@type-binding.type': syntheticCapture('@type-binding.type', anchorNode, typeText),
};
return m;
}

View file

@ -0,0 +1,97 @@
/**
* Java `ScopeResolver` registered in `SCOPE_RESOLVERS` and consumed by
* the generic `runScopeResolution` orchestrator (RFC #909 Ring 3).
*
* ## Registry-primary parity status
*
* Java is **not** in `MIGRATED_LANGUAGES` the scope-resolution
* registry runs in shadow mode only. Parity in forced registry mode
* (`REGISTRY_PRIMARY_JAVA=1`) is 143/172 (83%). The 29 gaps fall into:
*
* - switch pattern binding / sealed-class exhaustiveness
* - Map.values() / entrySet() iteration type propagation
* - assignment / method chain return-type propagation across files
* - virtual dispatch / interface default methods
*
* These are the same category of advanced-resolution gaps seen in prior
* migrations (Python, C#, Go). Parity is below the 99% flip threshold
* per RFC §6.4.
*
* **CI visibility:** Because Java is absent from `MIGRATED_LANGUAGES`,
* the parity CI workflow (`ci-scope-parity.yml`) does not run Java in
* either `REGISTRY_PRIMARY_JAVA=0` or `=1` mode. Regressions in forced
* mode are only visible via manual `REGISTRY_PRIMARY_JAVA=1 npx vitest
* run java.test.ts`. Before flipping Java to registry-primary, a
* non-required CI step should be added to run Java tests in forced mode
* and report parity as a dashboard input.
*
* **Parity baseline (29 failures):** The 29 gaps in forced registry mode
* are tracked in this PR (#1482) and this JSDoc. If the gap count
* changes (up or down), update this baseline accordingly.
*
* ### Known flip-blockers (must fix before adding to MIGRATED_LANGUAGES)
*
* - Varargs arity: fixed-prefix count is now preserved, but no
* integration fixture exercises the 0-arg rejection path yet.
* - Static import resolution: `import static X.Y.m` now correctly
* resolves to `X/Y.java` (the class), not `X/Y/m.java` (the member).
* Edge cases with nested classes may remain.
* - Generic superclass receiver binding: `BaseModel<T>` now strips
* to `BaseModel` via JVM type-erasure fallback in `stripGeneric`.
* - Wildcard import (`import com.example.*`) file selection is
* nondeterministic when multiple classes share a package directory.
* May produce wrong-file edges in forced mode.
* - Qualified generic type parameters in field/parameter annotations
* (`com.example.BaseModel<T>`) rare in practice but may miss
* resolution when the full qualifier is present with generics.
*/
import type { ParsedFile } from 'gitnexus-shared';
import { SupportedLanguages } from 'gitnexus-shared';
import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js';
import { populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js';
import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js';
import { javaProvider } from '../java.js';
import {
javaArityCompatibility,
javaMergeBindings,
resolveJavaImportTarget,
type JavaResolveContext,
} from './index.js';
const javaScopeResolver: ScopeResolver = {
language: SupportedLanguages.Java,
languageProvider: javaProvider,
importEdgeReason: 'java-scope: import',
resolveImportTarget: (targetRaw, fromFile, allFilePaths) => {
const ws: JavaResolveContext = { fromFile, allFilePaths };
return resolveJavaImportTarget(
{ kind: 'named', localName: '_', importedName: '_', targetRaw },
ws,
);
},
mergeBindings: (existing, incoming) => [...javaMergeBindings([...existing, ...incoming])],
arityCompatibility: (callsite, def) => javaArityCompatibility(def, callsite),
buildMro: (graph, parsedFiles, nodeLookup) =>
buildMro(graph, parsedFiles, nodeLookup, defaultLinearize),
populateOwners: (parsed: ParsedFile) => populateClassOwnedMembers(parsed),
isSuperReceiver: (text) => text.trim() === 'super',
// Java is statically typed — field-fallback heuristic stays off
fieldFallbackOnMethodLookup: false,
propagatesReturnTypesAcrossImports: true,
// Java doesn't collapse member calls
collapseMemberCallsByCallerTarget: false,
// Hoist return-type bindings to Module scope for cross-file propagation
hoistTypeBindingsToModule: true,
};
export { javaScopeResolver };

View file

@ -0,0 +1,54 @@
/**
* Small hooks for the Java provider. Each is a few lines; they make
* the provider's choice explicit rather than relying on defaults.
*/
import type {
CaptureMatch,
ParsedImport,
Scope,
ScopeId,
ScopeTree,
TypeRef,
} from 'gitnexus-shared';
// ─── bindingScopeFor ──────────────────────────────────────────────────────
/** Method return-type bindings hoist to Module scope so cross-file
* `propagateImportedReturnTypes` and chain-follow can find them. */
export function javaBindingScopeFor(
decl: CaptureMatch,
innermost: Scope,
tree: ScopeTree,
): ScopeId | null {
if (decl['@type-binding.return'] !== undefined) {
let cur: Scope | undefined = innermost;
while (cur !== undefined && cur.kind !== 'Module') {
const parentId: ScopeId | null = cur.parent ?? null;
if (parentId === null) break;
cur = tree.getScope(parentId);
}
if (cur !== undefined && cur.kind === 'Module') return cur.id;
}
return null;
}
// ─── importOwningScope ────────────────────────────────────────────────────
/** Java imports are always at compilation-unit (Module) level (JLS §7.5).
* Return `null` unconditionally so the default Module scope is used. */
export function javaImportOwningScope(
_imp: ParsedImport,
_innermost: Scope,
_tree: ScopeTree,
): ScopeId | null {
return null;
}
// ─── receiverBinding ──────────────────────────────────────────────────────
/** Look up `this` or `super` in the function scope's type bindings. */
export function javaReceiverBinding(functionScope: Scope): TypeRef | null {
if (functionScope.kind !== 'Function') return null;
return functionScope.typeBindings.get('this') ?? functionScope.typeBindings.get('super') ?? null;
}

View file

@ -5,12 +5,22 @@
* and standard export/import resolution. PHP files can use a variety of
* extensions from legacy versions through modern PHP 8.
*/
import {
emitPhpScopeCaptures,
interpretPhpImport,
interpretPhpTypeBinding,
phpArityCompatibility,
phpMergeBindings,
resolvePhpImportTarget,
phpBindingScopeFor,
phpImportOwningScope,
phpReceiverBinding,
} from './php/index.js';
import { SupportedLanguages } from 'gitnexus-shared';
import { createClassExtractor } from '../class-extractors/generic.js';
import { phpClassConfig } from '../class-extractors/configs/php.js';
import { defineLanguage } from '../language-provider.js';
import type { AstFrameworkPatternConfig } from '../language-provider.js';
import { defineLanguage, type AstFrameworkPatternConfig } from '../language-provider.js';
import { typeConfig as phpConfig } from '../type-extractors/php.js';
import { phpExportChecker } from '../export-detection.js';
import { createImportResolver } from '../import-resolvers/resolver-factory.js';
@ -289,4 +299,18 @@ export const phpProvider = defineLanguage({
descriptionExtractor: phpDescriptionExtractor,
isRouteFile: isPhpRouteFile,
builtInNames: BUILT_INS,
// ── RFC #909 Ring 3: scope-based resolution hooks ──────────────────────
emitScopeCaptures: emitPhpScopeCaptures,
interpretImport: interpretPhpImport,
interpretTypeBinding: interpretPhpTypeBinding,
// LanguageProvider uses (def, callsite); phpArityCompatibility uses (def, callsite) — same.
arityCompatibility: phpArityCompatibility,
// LanguageProvider adapter: (parsedImport, workspaceIndex) → string | null
resolveImportTarget: resolvePhpImportTarget,
// mergeBindings on LanguageProvider: (scope, bindings) — ignore scope id,
// delegate to phpMergeBindings which uses binding origin tiers.
mergeBindings: (_scope, bindings) => [...phpMergeBindings(bindings)],
bindingScopeFor: phpBindingScopeFor,
importOwningScope: phpImportOwningScope,
receiverBinding: phpReceiverBinding,
});

View file

@ -0,0 +1,73 @@
/**
* Extract PHP arity metadata from a method-like tree-sitter node
* `method_declaration` or `function_definition`.
*
* Reuses `phpMethodConfig.extractParameters` so scope-extracted defs
* carry the same arity semantics as the legacy parse-worker path:
* - `variadic_parameter` (`...$args`) collapses `parameterCount` to
* `undefined`, which `phpArityCompatibility` then treats as
* "max unknown" the candidate stays eligible at `argCount >= required`.
* - Defaulted parameters (`= expr`) contribute to `optionalCount`;
* `requiredParameterCount = total optionalCount (variadic ? 1 : 0)`.
* The variadic slot itself accepts zero args so it is subtracted from
* the required count `f(int $a, ...$rest)` requires exactly 1 arg,
* not 2, and `f(...$rest)` requires 0.
* - `property_promotion_parameter` (constructor-promoted) is counted
* the same as `simple_parameter` since both consume an argument slot.
* - `parameterTypes` collects declared type names; a literal `'...'`
* marker is appended for variadic methods so `phpArityCompatibility`
* can detect them without re-reading the AST.
*/
import type { SyntaxNode } from '../../utils/ast-helpers.js';
import { phpMethodConfig } from '../../method-extractors/configs/php.js';
interface PhpArityMetadata {
readonly parameterCount: number | undefined;
readonly requiredParameterCount: number | undefined;
readonly parameterTypes: readonly string[] | undefined;
}
export function computePhpArityMetadata(fnNode: SyntaxNode): PhpArityMetadata {
const params = phpMethodConfig.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);
}
// PHP variadic marker convention: append the literal '...' string to
// `parameterTypes`. This is intentionally DIFFERENT from C#, which uses
// the literal 'params' (its source-language keyword). The shared
// `narrowOverloadCandidates` pass in `scope-resolution/passes/overload-
// narrowing.ts` checks for the C# 'params' marker — that branch is
// dead code for PHP because PHP variadic methods set `parameterCount
// = undefined` (see line below), which skips the `max !== undefined`
// gate that hosts the 'params' check. PHP's actual variadic-aware
// arity logic lives in `phpArityCompatibility` (arity.ts) and now
// also in `phpEmitUnresolvedReceiverEdges` (scope-resolver.ts), both
// of which check `'...'`. Finding 9 of PR #1497 adversarial review.
if (hasVariadic) types.push('...');
const total = params.length;
// Variadic methods accept any arg count ≥ required — leave `parameterCount`
// undefined so the registry treats max as unknown.
const parameterCount = hasVariadic ? undefined : total;
// The variadic slot itself accepts zero args; subtract it from the required
// count so PHP's ArgumentCountError-equivalent calls (too few args before
// the variadic) are correctly rejected by arity compatibility.
const requiredParameterCount = total - optionalCount - (hasVariadic ? 1 : 0);
return {
parameterCount,
requiredParameterCount,
parameterTypes: types.length > 0 ? types : undefined,
};
}

View file

@ -0,0 +1,47 @@
/**
* PHP arity check, accommodating variadic (`...$args`) and default parameters.
*
* The `def` metadata synthesized by `arity-metadata.ts`:
* - `parameterCount` total formal parameters; `undefined` when
* the method has a variadic `...$param`.
* - `requiredParameterCount` min required (excludes defaulted params
* and the variadic itself).
* - `parameterTypes` declared type strings; contains the
* literal `'...'` when the method is variadic.
*
* Verdicts:
* - `'compatible'` `required <= argCount <= max`, OR the def has
* variadic (any `argCount >= required`).
* - `'incompatible'` argCount below required, or above max with no variadic.
* - `'unknown'` metadata absent / incomplete; named-args can satisfy
* any arity so we return unknown when we detect them.
*
* PHP supports named arguments (PHP 8.0+): `save(force: true)`. Named-arg
* call sites cannot be arity-checked statically without parsing arg names,
* so we return `'unknown'` when the callsite carries named args (signalled
* by a negative `arity` value per the shared Callsite contract).
*/
import type { Callsite, SymbolDefinition } from 'gitnexus-shared';
export function phpArityCompatibility(
def: SymbolDefinition,
callsite: Callsite,
): 'compatible' | 'unknown' | 'incompatible' {
const max = def.parameterCount;
const min = def.requiredParameterCount;
if (max === undefined && min === undefined) return 'unknown';
const argCount = callsite.arity;
// Negative arity signals named-argument call sites — can't narrow statically.
if (!Number.isFinite(argCount) || argCount < 0) return 'unknown';
const hasVarArgs =
def.parameterTypes !== undefined &&
def.parameterTypes.some((t) => t === '...' || t.startsWith('...'));
if (min !== undefined && argCount < min) return 'incompatible';
if (max !== undefined && argCount > max && !hasVarArgs) return 'incompatible';
return 'compatible';
}

View file

@ -0,0 +1,30 @@
/**
* Dev-mode counters for the cross-phase scope-captures parse cache
* (PHP mirror of `languages/csharp/cache-stats.ts`).
*
* Gated by `PROF_SCOPE_RESOLUTION=1`. Production builds fold every
* increment into dead code via the module-level `PROF` constant, so
* the hot path in `captures.ts` stays branch-free.
*/
const PROF = process.env.PROF_SCOPE_RESOLUTION === '1';
let CACHE_HITS = 0;
let CACHE_MISSES = 0;
export function recordCacheHit(): void {
if (PROF) CACHE_HITS++;
}
export function recordCacheMiss(): void {
if (PROF) CACHE_MISSES++;
}
export function getPhpCaptureCacheStats(): { hits: number; misses: number } {
return { hits: CACHE_HITS, misses: CACHE_MISSES };
}
export function resetPhpCaptureCacheStats(): void {
CACHE_HITS = 0;
CACHE_MISSES = 0;
}

View file

@ -0,0 +1,806 @@
/**
* `emitScopeCaptures` for PHP (RFC #909 Ring 3 LANG-php).
*
* Drives the PHP scope query against tree-sitter-php and groups raw
* matches into `CaptureMatch[]` for the central extractor. Layers two
* synthesized streams on top:
*
* 1. **Decomposed use declarations** each `namespace_use_declaration`
* is re-emitted with `@import.kind/source/name/alias` markers so
* `interpretPhpImport` can recover the ParsedImport shape without
* re-parsing raw text. Grouped uses fan out to one match per clause.
*
* 2. **Receiver-binding synthesis** `$this` and `parent` type-bindings
* are synthesized on every non-static method entry. PHP's grammar
* does not express "implicit receiver of a non-static class method"
* via a clean `.scm` pattern, so we walk up the AST in code.
*
* 3. **Arity metadata synthesis** `@declaration.parameter-count` /
* `@declaration.required-parameter-count` / `@declaration.parameter-types`
* are synthesized on function-like declarations so the registry can
* narrow overloads.
*
* 4. **PHPDoc synthesis** @param and @return annotations in comment
* nodes preceding method/function declarations are extracted and emitted
* as `@type-binding.parameter` and `@type-binding.return` matches.
*
* 5. **Foreach loop synthesis** `foreach ($users as $user)` emits
* a `@type-binding.alias` match binding the loop variable to the
* element type of the iterable (resolved from PHPDoc or scopeEnv).
*
* Pure given the input source text. No I/O, no globals consulted.
*/
import type { Capture, CaptureMatch } from 'gitnexus-shared';
import { findNodeAtRange, nodeToCapture, syntheticCapture } from '../../utils/ast-helpers.js';
import { splitNamespaceUseDeclaration } from './import-decomposer.js';
import { computePhpArityMetadata } from './arity-metadata.js';
import { synthesizePhpReceiverBinding } from './receiver-binding.js';
import { getPhpParser, getPhpScopeQuery } from './query.js';
import { recordCacheHit, recordCacheMiss } from './cache-stats.js';
import { getTreeSitterBufferSize } from '../../constants.js';
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
type SyntaxNode = ReturnType<ReturnType<typeof getPhpParser>['parse']>['rootNode'];
/** Declaration anchors that carry function-like arity metadata. */
const FUNCTION_DECL_TAGS = ['@declaration.method', '@declaration.function'] as const;
/** tree-sitter-php node types that the method extractor accepts. */
const FUNCTION_NODE_TYPES = [
'method_declaration',
'function_definition',
'anonymous_function',
'arrow_function',
] as const;
export function emitPhpScopeCaptures(
sourceText: string,
_filePath: string,
cachedTree?: unknown,
): readonly CaptureMatch[] {
// Skip the parse when the caller already produced a Tree for this source.
// The cachedTree parameter is typed as `unknown` at the LanguageProvider
// contract layer; cast here at the use site.
let tree = cachedTree as ReturnType<ReturnType<typeof getPhpParser>['parse']> | undefined;
if (tree === undefined) {
tree = parseSourceSafe(getPhpParser(), sourceText, undefined, {
bufferSize: getTreeSitterBufferSize(sourceText),
});
recordCacheMiss();
} else {
recordCacheHit();
}
const rawMatches = getPhpScopeQuery().matches(tree.rootNode);
const out: CaptureMatch[] = [];
// Pre-scan: collect anchor node IDs of property_declaration nodes already
// matched by the typed @declaration.property pattern (query.ts ~lines 9598).
// The untyped @declaration.variable catch-all (query.ts ~lines 101103) is
// intentionally loose — it has no `type:` constraint, so tree-sitter also
// matches it against typed property declarations and emits a second capture
// for the same property_declaration anchor. Graph-level def-id collision
// currently masks the duplicate at the node-emit layer, but the catch-all
// capture still flows through scope-binding / name-keyed registries with a
// `$`-prefixed name that the typed branch's `$`-strip never normalizes —
// a known vector for receiver-binding lookup pollution. The two patterns
// produce separate rawMatches entries with separate `grouped` maps, so the
// dedup has to be cross-match: build the set here, then skip
// @declaration.variable matches whose anchor is in it (loop below).
const typedPropertyAnchorIds = new Set<number>();
for (const m of rawMatches) {
for (const c of m.captures) {
if (c.name === 'declaration.property') {
typedPropertyAnchorIds.add(c.node.id);
break;
}
}
}
for (const m of rawMatches) {
// Group captures by their tag name. Tree-sitter strips the leading
// `@`; we put it back so the central extractor's prefix lookups work.
const grouped: Record<string, Capture> = {};
for (const c of m.captures) {
const tag = '@' + c.name;
grouped[tag] = nodeToCapture(tag, c.node);
}
if (Object.keys(grouped).length === 0) continue;
// Cross-match dedup for the typed-property double-match described above:
// skip @declaration.variable matches whose anchor was already captured as
// @declaration.property in an earlier match.
if (grouped['@declaration.variable'] !== undefined) {
const varCap = m.captures.find((c) => c.name === 'declaration.variable');
if (varCap !== undefined && typedPropertyAnchorIds.has(varCap.node.id)) continue;
}
// Normalize PHP property declarations: strip leading `$` from
// `@declaration.name` for @declaration.property matches. PHP stores
// field names WITHOUT the `$` sigil in the graph so that member access
// lookups like `$user->address` can find the property named `address`
// (not `$address`). `@type-binding.annotation` already strips `$` in
// `interpretPhpTypeBinding`; this mirrors that for the declaration side.
//
// Only applies to `@declaration.property` — typed class properties and
// constructor-promoted parameters. Untyped `@declaration.variable` keeps
// its `$` prefix (those defs are Variable type and not in the field
// registry, so their name doesn't affect member lookup).
if (
grouped['@declaration.property'] !== undefined &&
grouped['@declaration.name'] !== undefined
) {
const nameCap = grouped['@declaration.name'];
if (nameCap.text.startsWith('$')) {
grouped['@declaration.name'] = { ...nameCap, text: nameCap.text.slice(1) };
}
}
// Normalize PHP receiver expressions so the compound-receiver resolver
// can walk chains expressed with `->` (PHP) as if they used `.` (the
// resolver's canonical separator). Without this, `$user->address->save()`
// has receiver text `$user->address` — the resolver sees no `.` separator,
// treats it as a bare identifier, and cannot walk field types.
//
// Transformation applied to `@reference.receiver` captures:
// 1. Replace `->` with `.` ($user->address → $user.address)
// 2. Strip leading `$` from each segment ($user.address → user.address)
// 3. Strip trailing `?` on null-safe receivers ($user? → user)
//
// This is a PHP-local normalization — no shared pipeline code is changed.
if (grouped['@reference.receiver'] !== undefined) {
const recvCap = grouped['@reference.receiver']!;
const normalized = normalizePhpReceiver(recvCap.text);
if (normalized !== recvCap.text) {
grouped['@reference.receiver'] = { ...recvCap, text: normalized };
}
}
// Normalize static property write: strip leading `$` from `@reference.name`
// so `User::$count` resolves to property `count` (stored without `$` in graph).
if (grouped['@reference.write.static'] !== undefined) {
const nameCap = grouped['@reference.name'];
if (nameCap !== undefined && nameCap.text.startsWith('$')) {
grouped['@reference.name'] = {
...nameCap,
text: nameCap.text.slice(1),
};
}
// Re-tag as @reference.write.member so downstream passes see a uniform write kind.
grouped['@reference.write.member'] = grouped['@reference.write.static']!;
delete grouped['@reference.write.static'];
}
// Decompose each `namespace_use_declaration` so `interpretPhpImport`
// sees the kind/source/name/alias markers it consumes.
if (grouped['@import.statement'] !== undefined) {
const stmtCapture = grouped['@import.statement'];
const stmtNode = findNodeAtRange(
tree.rootNode,
stmtCapture.range,
'namespace_use_declaration',
);
if (stmtNode !== null) {
const decomposed = splitNamespaceUseDeclaration(stmtNode);
if (decomposed.length > 0) {
for (const d of decomposed) out.push(d);
continue;
}
}
// Defensive fallback: emit the raw match.
out.push(grouped);
continue;
}
// Synthesize `$this` / `parent` receiver type-bindings on every
// non-static method-like. Mirrors C#'s `this` / `base` synthesis.
if (grouped['@scope.function'] !== undefined) {
out.push(grouped);
const anchor = grouped['@scope.function']!;
const fnNode = findFunctionNode(tree.rootNode, anchor.range);
if (fnNode !== null) {
for (const synth of synthesizePhpReceiverBinding(fnNode)) {
out.push(synth);
}
// Synthesize PHPDoc @param and @return type bindings for this fn.
for (const synth of synthesizePhpDocBindings(fnNode)) {
out.push(synth);
}
// Synthesize foreach loop variable bindings inside this fn body.
for (const synth of synthesizeForeachBindings(fnNode)) {
out.push(synth);
}
}
continue;
}
// Synthesize arity metadata on function-like declarations so the
// registry can narrow overloads.
const declTag = FUNCTION_DECL_TAGS.find((t) => grouped[t] !== undefined);
if (declTag !== undefined) {
const anchor = grouped[declTag]!;
const fnNode = findFunctionNode(tree.rootNode, anchor.range);
if (fnNode !== null) {
const arity = computePhpArityMetadata(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) {
grouped['@declaration.parameter-types'] = syntheticCapture(
'@declaration.parameter-types',
fnNode,
JSON.stringify(arity.parameterTypes),
);
}
}
}
// Synthesize `@reference.arity` on every call site so the registry's
// arity filter can narrow overloads. Count the `argument` children of
// the backing `arguments` node. Mirrors C#'s pattern (csharp/captures.ts
// lines 149-186). PHP needs this for arity-based dispatch (Cluster H).
const callTag = (
['@reference.call.free', '@reference.call.member', '@reference.call.constructor'] as const
).find((t) => grouped[t] !== undefined);
if (callTag !== undefined && grouped['@reference.arity'] === undefined) {
const anchor = grouped[callTag]!;
const callNode =
findNodeAtRange(tree.rootNode, anchor.range, 'function_call_expression') ??
findNodeAtRange(tree.rootNode, anchor.range, 'member_call_expression') ??
findNodeAtRange(tree.rootNode, anchor.range, 'nullsafe_member_call_expression') ??
findNodeAtRange(tree.rootNode, anchor.range, 'scoped_call_expression') ??
findNodeAtRange(tree.rootNode, anchor.range, 'object_creation_expression');
if (callNode !== null) {
const argList = callNode.childForFieldName('arguments');
const args: SyntaxNode[] = [];
if (argList !== null) {
for (let i = 0; i < argList.namedChildCount; i++) {
const child = argList.namedChild(i);
if (child !== null && child.type === 'argument') args.push(child);
}
}
grouped['@reference.arity'] = syntheticCapture(
'@reference.arity',
callNode,
String(args.length),
);
// Infer argument types from literal nodes for type-based narrowing.
// Non-literal arguments emit empty string ("unknown" = any-match).
const argTypes = args.map((arg) => inferPhpArgType(arg));
grouped['@reference.parameter-types'] = syntheticCapture(
'@reference.parameter-types',
callNode,
JSON.stringify(argTypes),
);
}
}
out.push(grouped);
}
return out;
}
/** Find the first PHP function-like node at the given range. */
function findFunctionNode(rootNode: SyntaxNode, range: Capture['range']): SyntaxNode | null {
for (const nodeType of FUNCTION_NODE_TYPES) {
const n = findNodeAtRange(rootNode, range, nodeType);
if (n !== null) return n as SyntaxNode;
}
return null;
}
// ─── PHP receiver normalization ──────────────────────────────────────────────
/**
* Normalize a PHP receiver expression so the language-agnostic
* compound-receiver resolver (which splits on `.`) can walk field-type chains.
*
* The compound-receiver resolver:
* - splits on `.` to get chain segments
* - looks up the first segment in `typeBindings` (keyed with `$` for variables)
* - walks subsequent segments as field names (stored without `$` in the graph)
*
* Transformation:
* 1. Replace `->` and `?->` with `.` so the resolver's splitter works
* 2. Strip any bare `?` fragment left by null-safe chain ends
* 3. Strip `$` from all segments EXCEPT the first (which is a variable
* and must keep `$` for typeBindings lookup e.g. `$user → User`)
*
* Examples:
* `$user` `$user` (bare variable unchanged)
* `$user->address` `$user.address`
* `$user->address->city` `$user.address.city`
* `$user?` `$user` (null-safe trailing `?` stripped)
* `$this` `$this` (receiverBinding uses `$this`)
* `parent` `parent` (super-receiver check)
*/
function normalizePhpReceiver(raw: string): string {
// Keep `$this`, `parent`, and `self` as-is.
if (raw === '$this' || raw === 'parent' || raw === 'self') return raw;
// Replace `?->` (null-safe) and plain `->` with `.`.
let text = raw.replace(/\?->/g, '.').replace(/->/g, '.');
// Strip a trailing `?` (null-safe fragment on the last object node).
text = text.replace(/\?$/, '');
// Collapse any doubled dots from `?->` where `?` was on its own.
text = text.replace(/\.{2,}/g, '.');
// Strip trailing dot.
text = text.replace(/\.$/, '');
// Split on `.` and strip `$` from all segments EXCEPT the first.
// The first segment is a PHP variable (typeBinding key includes `$`).
// Subsequent segments are property/method names (stored without `$`).
const segments = text.split('.');
for (let i = 1; i < segments.length; i++) {
const s = segments[i];
if (s !== undefined && s.startsWith('$')) segments[i] = s.slice(1);
}
return segments.join('.');
}
// ─── PHP argument type inference ─────────────────────────────────────────────
/**
* Infer the PHP type of a call argument from its literal shape.
* Returns an empty string for non-literals (treated as "unknown" = any-match).
* Mirrors C#'s `inferArgType` helper.
*/
function inferPhpArgType(argNode: SyntaxNode): string {
// argument node wraps the actual expression
const expr = argNode.firstNamedChild ?? argNode;
switch (expr.type) {
case 'integer':
return 'int';
case 'float':
return 'float';
case 'string':
case 'encapsed_string':
case 'heredoc':
case 'nowdoc':
return 'string';
case 'boolean':
case 'true':
case 'false':
return 'bool';
case 'null':
return 'null';
default:
return '';
}
}
// ─── PHPDoc synthesis ─────────────────────────────────────────────────────────
/** PHP 8+ attribute_list nodes that appear between PHPDoc and method. */
const SKIP_SIBLING_TYPES = new Set(['attribute_list', 'attribute', 'comment']);
/** Regex for PHPDoc @param: standard `@param Type $name` */
const PHPDOC_PARAM_RE = /@param\s+(\S+)\s+\$(\w+)/g;
/** Regex for PHPDoc @param: alternate `@param $name Type` */
const PHPDOC_PARAM_ALT_RE = /@param\s+\$(\w+)\s+(\S+)/g;
/** Regex for PHPDoc @return: `@return Type` */
const PHPDOC_RETURN_RE = /@return\s+(\S+)/;
/**
* Normalize a PHP type string to a simple class name for binding purposes.
* Returns null for primitives or uninformative types.
* Mirrors `normalizePhpType` in `interpret.ts` but operates on raw PHPDoc strings.
*/
function normalizePhpDocType(raw: string): string | null {
let type = raw.trim();
// Strip nullable prefix
if (type.startsWith('?')) type = type.slice(1).trim();
// Strip array suffix: User[] → User
if (type.endsWith('[]')) type = type.slice(0, -2).trim();
// Strip union with null/false/void
if (type.includes('|')) {
const parts = type
.split('|')
.map((p) => p.trim())
.filter((p) => p !== 'null' && p !== 'false' && p !== 'void' && p !== 'mixed' && p !== '');
if (parts.length !== 1) return null;
type = parts[0];
}
// Strip intersection: take first part
if (type.includes('&')) {
const first = type.split('&')[0].trim();
if (first === '') return null;
type = first;
}
// Strip generic wrapper: Collection<User> → User
const genericMatch = type.match(/^\w[\w\\]*\s*<([^,<>]+)>$/);
if (genericMatch) {
type = genericMatch[1].trim();
// Strip array suffix again inside generic
if (type.endsWith('[]')) type = type.slice(0, -2).trim();
}
// Strip namespace qualifier: \App\Models\User → User
if (type.includes('\\')) {
const segs = type.split('\\').filter(Boolean);
type = segs[segs.length - 1] ?? type;
}
// Reject primitives
if (PHP_PRIMITIVES.has(type.toLowerCase())) return null;
// Must be a simple identifier
if (!/^\w+$/.test(type)) return null;
return type;
}
const PHP_PRIMITIVES = new Set([
'int',
'integer',
'float',
'double',
'string',
'bool',
'boolean',
'array',
'object',
'callable',
'iterable',
'null',
'void',
'never',
'mixed',
'false',
'true',
'self',
'static',
'parent',
]);
/**
* Collect comment text from siblings immediately before `fnNode`.
* Skips PHP 8+ attribute_list nodes.
*/
function collectPrecedingComments(fnNode: SyntaxNode): string {
const texts: string[] = [];
let sibling = fnNode.previousSibling;
while (sibling !== null) {
if (sibling.type === 'comment') {
texts.unshift(sibling.text);
} else if (sibling.isNamed && !SKIP_SIBLING_TYPES.has(sibling.type)) {
break;
}
sibling = sibling.previousSibling;
}
return texts.join('\n');
}
/**
* Synthesize PHPDoc @param and @return type-binding captures for a
* method_declaration or function_definition node.
*
* PHPDoc @param Type $name `@type-binding.parameter` match (anchored at fn body/return_type).
* PHPDoc @return Type `@type-binding.return` match (anchored at fn name).
*/
function synthesizePhpDocBindings(fnNode: SyntaxNode): CaptureMatch[] {
if (fnNode.type !== 'method_declaration' && fnNode.type !== 'function_definition') return [];
const commentBlock = collectPrecedingComments(fnNode);
if (commentBlock === '') return [];
const out: CaptureMatch[] = [];
// Anchor for parameter type-bindings: the function body (or return_type as fallback).
// The binding must be inside the function scope so it's visible to body statements.
const bodyNode = fnNode.childForFieldName('body');
const anchorNode = bodyNode ?? fnNode;
// ── @param annotations ────────────────────────────────────────────────────
PHPDOC_PARAM_RE.lastIndex = 0;
let m: RegExpExecArray | null;
const seenParams = new Set<string>();
while ((m = PHPDOC_PARAM_RE.exec(commentBlock)) !== null) {
const rawType = m[1];
const paramName = '$' + m[2];
const typeName = normalizePhpDocType(rawType);
if (typeName === null) continue;
seenParams.add(paramName);
out.push({
'@type-binding.parameter': nodeToCapture('@type-binding.parameter', anchorNode),
'@type-binding.name': syntheticCapture('@type-binding.name', anchorNode, paramName),
'@type-binding.type': syntheticCapture('@type-binding.type', anchorNode, typeName),
});
}
// Also check alternate PHPDoc order: @param $name Type
PHPDOC_PARAM_ALT_RE.lastIndex = 0;
while ((m = PHPDOC_PARAM_ALT_RE.exec(commentBlock)) !== null) {
const paramName = '$' + m[1];
if (seenParams.has(paramName)) continue; // standard format takes priority
const rawType = m[2];
const typeName = normalizePhpDocType(rawType);
if (typeName === null) continue;
out.push({
'@type-binding.parameter': nodeToCapture('@type-binding.parameter', anchorNode),
'@type-binding.name': syntheticCapture('@type-binding.name', anchorNode, paramName),
'@type-binding.type': syntheticCapture('@type-binding.type', anchorNode, typeName),
});
}
// ── @return annotation ────────────────────────────────────────────────────
const returnMatch = PHPDOC_RETURN_RE.exec(commentBlock);
if (returnMatch !== null) {
const rawType = returnMatch[1];
const typeName = normalizePhpDocType(rawType);
if (typeName !== null) {
// @return bindings must be anchored at the method name and hoisted to Module scope
// by phpBindingScopeFor (which checks for @type-binding.return presence).
// Use the function_definition/method_declaration node itself as the anchor — it
// coincides with the innermost scope's range, so auto-hoist kicks in.
const nameNode = fnNode.childForFieldName('name') ?? fnNode;
out.push({
'@type-binding.return': nodeToCapture('@type-binding.return', fnNode),
'@type-binding.name': syntheticCapture('@type-binding.name', nameNode, nameNode.text),
'@type-binding.type': syntheticCapture('@type-binding.type', nameNode, typeName),
});
}
}
return out;
}
// ─── Foreach synthesis ───────────────────────────────────────────────────────
/**
* Walk all `foreach_statement` nodes inside `fnNode` and synthesize
* `@type-binding.alias` captures binding the loop variable to the
* element type of the iterable.
*
* Supports:
* - `foreach ($users as $user)` simple iterable variable
* - `foreach ($users as $k => $user)` keyvalue pair
* - `foreach ($this->users as $user)` member access iterable
* - `foreach (getUsers() as $user)` NOT yet supported (needs return type)
*
* The element type is resolved by:
* 1. Looking up the iterable name in PHPDoc @param bindings already
* collected for this function (passed via typeBindingsByName).
* 2. Direct resolution when iterable's env type IS the element type
* (because PHPDoc normalizes `User[]` `User` already).
*/
function synthesizeForeachBindings(fnNode: SyntaxNode): CaptureMatch[] {
if (
fnNode.type !== 'method_declaration' &&
fnNode.type !== 'function_definition' &&
fnNode.type !== 'anonymous_function' &&
fnNode.type !== 'arrow_function'
) {
return [];
}
const out: CaptureMatch[] = [];
// Build a mini type map from the function's PHPDoc @param annotations.
// This is re-parsed here (not cached from synthesizePhpDocBindings) for simplicity;
// the cost is negligible given the small comment sizes.
const commentBlock = collectPrecedingComments(fnNode);
const paramTypeMap = buildParamTypeMap(commentBlock);
// Walk the function body for foreach_statement nodes.
const bodyNode = fnNode.childForFieldName('body');
if (bodyNode === null) return [];
collectForeachBindings(bodyNode, fnNode, paramTypeMap, out);
return out;
}
/** Build a map of `$paramName → elementTypeName` from PHPDoc @param in a comment block. */
function buildParamTypeMap(commentBlock: string): Map<string, string> {
const map = new Map<string, string>();
if (commentBlock === '') return map;
PHPDOC_PARAM_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = PHPDOC_PARAM_RE.exec(commentBlock)) !== null) {
const rawType = m[1];
const paramName = '$' + m[2];
const typeName = normalizePhpDocType(rawType);
if (typeName !== null) map.set(paramName, typeName);
}
PHPDOC_PARAM_ALT_RE.lastIndex = 0;
while ((m = PHPDOC_PARAM_ALT_RE.exec(commentBlock)) !== null) {
const paramName = '$' + m[1];
if (map.has(paramName)) continue;
const rawType = m[2];
const typeName = normalizePhpDocType(rawType);
if (typeName !== null) map.set(paramName, typeName);
}
return map;
}
/**
* Walk a subtree and collect foreach_statement bindings.
* Recursively descends into all child nodes.
*/
function collectForeachBindings(
node: SyntaxNode,
fnNode: SyntaxNode,
paramTypeMap: Map<string, string>,
out: CaptureMatch[],
): void {
if (node.type === 'foreach_statement') {
const synth = synthesizeSingleForeach(node, fnNode, paramTypeMap);
if (synth !== null) out.push(synth);
}
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child !== null) {
collectForeachBindings(child, fnNode, paramTypeMap, out);
}
}
}
/**
* Synthesize a single `@type-binding.alias` match for a `foreach_statement`.
*
* AST structure for foreach_statement (tree-sitter-php):
* foreach ( <iterable> as <value_or_pair> ) <body>
* Named children (excluding body): first = iterable, second = value or pair.
*/
function synthesizeSingleForeach(
foreachNode: SyntaxNode,
fnNode: SyntaxNode,
paramTypeMap: Map<string, string>,
): CaptureMatch | null {
// Collect non-body named children: [iterable, value_or_pair]
const bodyNode = foreachNode.childForFieldName('body');
const children: SyntaxNode[] = [];
for (let i = 0; i < foreachNode.namedChildCount; i++) {
const child = foreachNode.namedChild(i);
if (child !== null && child !== bodyNode) children.push(child);
}
if (children.length < 2) return null;
const iterableNode = children[0];
const valueOrPair = children[1];
// Determine the loop variable node
let loopVarNode: SyntaxNode;
if (valueOrPair.type === 'pair') {
// $key => $value — use the last named child of the pair
const lastChild = valueOrPair.namedChild(valueOrPair.namedChildCount - 1);
if (lastChild === null) return null;
loopVarNode =
lastChild.type === 'by_ref' ? (lastChild.firstNamedChild ?? lastChild) : lastChild;
} else {
loopVarNode =
valueOrPair.type === 'by_ref' ? (valueOrPair.firstNamedChild ?? valueOrPair) : valueOrPair;
}
// Loop variable must be a variable_name
if (loopVarNode.type !== 'variable_name') return null;
const loopVarName = loopVarNode.text; // e.g. '$user'
// Resolve the element type from the iterable
let elementType: string | null = null;
if (iterableNode.type === 'variable_name') {
// foreach ($users as $user) — look up $users in param map
const iterableName = iterableNode.text; // e.g. '$users'
elementType = paramTypeMap.get(iterableName) ?? null;
} else if (iterableNode.type === 'member_access_expression') {
// foreach ($this->users as $user) — property name is the field
const propNameNode = iterableNode.childForFieldName('name');
if (propNameNode !== null) {
// Property stored with $ prefix in paramTypeMap (rare for $this->prop patterns)
// Try both with and without $ prefix
const propKey = '$' + propNameNode.text;
elementType = paramTypeMap.get(propKey) ?? null;
if (elementType === null) {
// Try to find the property type from the enclosing class
elementType = findClassPropertyElementType(iterableNode, fnNode);
}
}
} else if (iterableNode.type === 'function_call_expression') {
// foreach (getUsers() as $user) — use the function name as a type alias.
// The function's @return annotation produces a @type-binding.return binding
// in the Module scope (e.g. getUsers → User). The scope-extractor's
// followChainedRef will resolve $user → getUsers → User.
const funcNode = iterableNode.childForFieldName('function');
if (funcNode !== null && funcNode.type === 'name') {
elementType = funcNode.text; // e.g. 'getUsers' — chain will be resolved later
}
} else if (iterableNode.type === 'member_call_expression') {
// foreach ($this->getUsers() as $user) — use the method name as a type alias.
const methodNameNode = iterableNode.childForFieldName('name');
if (methodNameNode !== null) {
elementType = methodNameNode.text; // e.g. 'getUsers'
}
}
if (elementType === null) return null;
// Anchor the binding inside the foreach body so it's scoped to the loop.
const anchorNode = bodyNode ?? foreachNode;
return {
'@type-binding.alias': nodeToCapture('@type-binding.alias', anchorNode),
'@type-binding.name': syntheticCapture('@type-binding.name', anchorNode, loopVarName),
'@type-binding.type': syntheticCapture('@type-binding.type', anchorNode, elementType),
};
}
/**
* Try to find the element type for `$this->property` member access by walking
* up from the foreach to the enclosing class and scanning the property declaration.
*/
function findClassPropertyElementType(
memberAccessNode: SyntaxNode,
fnNode: SyntaxNode,
): string | null {
const propNameNode = memberAccessNode.childForFieldName('name');
if (propNameNode === null) return null;
const propName = propNameNode.text;
// Walk up from fnNode to find the enclosing class declaration
let cur: SyntaxNode | null = fnNode.parent;
while (cur !== null) {
if (cur.type === 'class_declaration' || cur.type === 'trait_declaration') {
break;
}
cur = cur.parent;
}
if (cur === null) return null;
// Find the property_declaration with matching variable_name '$propName'
const declList = cur.childForFieldName('body');
if (declList === null) return null;
for (let i = 0; i < declList.namedChildCount; i++) {
const child = declList.namedChild(i);
if (child === null || child.type !== 'property_declaration') continue;
for (let j = 0; j < child.namedChildCount; j++) {
const elem = child.namedChild(j);
if (elem === null || elem.type !== 'property_element') continue;
const varNameNode = elem.firstNamedChild;
if (varNameNode === null || varNameNode.text !== '$' + propName) continue;
// Found the property — get its element type from @var PHPDoc or native type
return extractPropertyElementType(child);
}
}
return null;
}
/** Regex for PHPDoc @var: `@var Type` */
const PHPDOC_VAR_RE = /@var\s+(\S+)/;
/**
* Extract element type from a property_declaration node:
* 1. PHPDoc @var annotation on a preceding comment sibling
* 2. PHP 7.4+ native type field (non-array)
*/
function extractPropertyElementType(propDecl: SyntaxNode): string | null {
// Strategy 1: PHPDoc @var on a preceding comment sibling
let sibling = propDecl.previousSibling;
while (sibling !== null) {
if (sibling.type === 'comment') {
const m = PHPDOC_VAR_RE.exec(sibling.text);
if (m !== null) return normalizePhpDocType(m[1]);
} else if (sibling.isNamed && !SKIP_SIBLING_TYPES.has(sibling.type)) {
break;
}
sibling = sibling.previousSibling;
}
// Strategy 2: native type field — skip generic 'array'
const typeNode = propDecl.childForFieldName('type');
if (typeNode === null) return null;
const typeName = typeNode.text.trim();
if (typeName === 'array' || typeName === '') return null;
return normalizePhpDocType(typeName);
}

View file

@ -0,0 +1,304 @@
/**
* Decompose a PHP `namespace_use_declaration` into one or more
* `CaptureMatch` objects carrying the synthesized markers
* `@import.kind` / `@import.source` / `@import.name` / `@import.alias`
* that `interpretPhpImport` consumes.
*
* PHP import forms handled:
*
* use Foo\Bar; namespace, localName=Bar
* use Foo\Bar as Baz; alias, localName=Baz
* use function Foo\bar; function, localName=bar
* use const Foo\BAR; const, localName=BAR
* use Foo\{A, B as C}; grouped: one match per clause
* use function Foo\{f, g as h}; grouped function variants
* use const Foo\{X, Y as Z}; grouped const variants
*
* Unlike C#'s decomposer this is 1:N each grouped use_declaration
* fans out to one CaptureMatch per inner clause.
*/
import type { Capture, CaptureMatch } from 'gitnexus-shared';
import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
export type PhpImportKind = 'namespace' | 'alias' | 'function' | 'const';
interface PhpImportSpec {
readonly kind: PhpImportKind;
/** Full backslash-separated path (backslashes intact): `Foo\Bar\Baz`. */
readonly source: string;
/** Local binding name last source segment for plain imports, the
* alias identifier for aliased imports. */
readonly name: string;
/** Present iff kind === 'alias'. */
readonly alias?: string;
/** Anchor node for synthesized captures (range-wise). */
readonly atNode: SyntaxNode;
}
/**
* Decompose a `namespace_use_declaration` node into one `CaptureMatch`
* per logical import. Returns `[]` when the node is unrecognized or
* carries no resolvable clauses.
*/
export function splitNamespaceUseDeclaration(stmtNode: SyntaxNode): CaptureMatch[] {
if (stmtNode.type !== 'namespace_use_declaration') return [];
// Detect qualifier keyword: `use function` / `use const`
// tree-sitter-php uses a `use_type` or `function`/`const` keyword
// child to distinguish them. We scan the raw text before the first
// backslash-path child.
const qualifier = detectQualifier(stmtNode);
// Grouped use: `use Foo\{A, B as C}` — find namespace_use_group child.
const groupNode = findNamedChild(stmtNode, 'namespace_use_group');
if (groupNode !== null) {
return decomposeGrouped(stmtNode, groupNode, qualifier);
}
// Single use clause (possibly aliased).
const spec = parseSingleUseClause(stmtNode, qualifier);
if (spec === null) return [];
return [buildImportMatch(stmtNode, spec)];
}
// ── Qualifier detection ────────────────────────────────────────────────────
/**
* Return the qualifier keyword appearing after `use`:
* `'function'`, `'const'`, or `null` for plain namespace use.
*
* tree-sitter-php emits the qualifier as a `name` node with text
* "function" or "const" (not a keyword token in recent grammars),
* or as a dedicated `use_type` node. We inspect the node's raw text
* to be grammar-version-agnostic.
*/
function detectQualifier(node: SyntaxNode): PhpImportKind {
const raw = node.text;
// Match `use function` or `use const` at the start (after optional whitespace)
if (/^\s*use\s+function\s/i.test(raw)) return 'function';
if (/^\s*use\s+const\s/i.test(raw)) return 'const';
return 'namespace';
}
// ── Single clause parsing ──────────────────────────────────────────────────
function parseSingleUseClause(node: SyntaxNode, qualifier: PhpImportKind): PhpImportSpec | null {
// A plain `namespace_use_declaration` has one or more
// `namespace_use_clause` named children (each clause is one import,
// comma-separated for multiple). For the single case there is one.
const clause = findNamedChild(node, 'namespace_use_clause');
if (clause !== null) return parseUseClause(clause, qualifier);
// Older grammar versions may put the qualified_name directly under
// the declaration node. Check for a qualified_name or name child.
const qualName = findNamedChild(node, 'qualified_name') ?? findNamedChild(node, 'name');
if (qualName === null) return null;
const source = qualName.text.trim();
if (source === '') return null;
return {
kind: qualifier,
source,
name: lastSegment(source),
atNode: node,
};
}
function parseUseClause(clause: SyntaxNode, qualifier: PhpImportKind): PhpImportSpec | null {
// namespace_use_clause:
// qualified_name (or name)
// optional: alias_clause → "as" name (some grammar versions)
// optional: bare name node (tree-sitter-php ≥ 0.22 emits the
// alias as a sibling `name` node
// directly, not inside alias_clause)
const qualName = findNamedChild(clause, 'qualified_name') ?? findNamedChild(clause, 'name');
if (qualName === null) return null;
const source = qualName.text.trim();
if (source === '') return null;
// Strategy 1: explicit alias_clause wrapper (older grammar versions).
const aliasClause = findNamedChild(clause, 'alias_clause');
if (aliasClause !== null) {
// alias_clause: "as" name
const aliasName = findNamedChild(aliasClause, 'name') ?? aliasClause.firstNamedChild;
const alias = aliasName?.text.trim() ?? '';
if (alias === '') return null;
return {
kind: 'alias',
source,
name: alias,
alias,
atNode: clause,
};
}
// Strategy 2: bare sibling `name` node after the qualified_name.
// tree-sitter-php (≥ 0.22) emits `use Foo\Bar as Baz` as:
// namespace_use_clause
// qualified_name "Foo\Bar"
// name "Baz" ← alias, no alias_clause wrapper
// Detect by: clause has ≥2 named children AND the last named child is
// a `name` node that differs from the qualName node.
if (clause.namedChildCount >= 2) {
const lastChild = clause.namedChild(clause.namedChildCount - 1);
if (lastChild !== null && lastChild !== qualName && lastChild.type === 'name') {
const alias = lastChild.text.trim();
if (alias !== '') {
return {
kind: 'alias',
source,
name: alias,
alias,
atNode: clause,
};
}
}
}
return {
kind: qualifier,
source,
name: lastSegment(source),
atNode: clause,
};
}
// ── Grouped use decomposition ──────────────────────────────────────────────
/**
* Decompose `use Foo\Bar\{A, B as C, function f, const X}` into one
* `CaptureMatch` per inner clause.
*
* The leading prefix (`Foo\Bar`) is prepended to each inner path.
* Inner clauses can override the qualifier with their own `function` /
* `const` keyword inside the group.
*/
function decomposeGrouped(
stmtNode: SyntaxNode,
groupNode: SyntaxNode,
outerQualifier: PhpImportKind,
): CaptureMatch[] {
// The prefix is the qualified_name that precedes the `{...}` group.
const prefixNode = findNamedChild(stmtNode, 'qualified_name') ?? findNamedChild(stmtNode, 'name');
const prefix = prefixNode?.text.trim() ?? '';
const out: CaptureMatch[] = [];
for (let i = 0; i < groupNode.namedChildCount; i++) {
const child = groupNode.namedChild(i);
if (child === null) continue;
// Each child in a group may be:
// namespace_use_clause — plain or aliased
// namespace_use_type — `function` or `const` qualifier inside group
// We detect an inline qualifier by checking the raw text of the clause.
if (child.type !== 'namespace_use_clause') continue;
const innerQualifier = detectInnerQualifier(child) ?? outerQualifier;
const spec = parseInnerClause(child, prefix, innerQualifier);
if (spec !== null) {
out.push(buildImportMatch(stmtNode, spec));
}
}
return out;
}
/**
* Detect an inline qualifier keyword inside a grouped clause.
* e.g. `use Foo\{function bar, const BAZ}` each clause may start with
* `function` or `const`.
*/
function detectInnerQualifier(clause: SyntaxNode): PhpImportKind | null {
const raw = clause.text.trim();
if (/^function\s/i.test(raw)) return 'function';
if (/^const\s/i.test(raw)) return 'const';
return null;
}
function parseInnerClause(
clause: SyntaxNode,
prefix: string,
qualifier: PhpImportKind,
): PhpImportSpec | null {
const qualName = findNamedChild(clause, 'qualified_name') ?? findNamedChild(clause, 'name');
if (qualName === null) return null;
// Strip inline `function` / `const` text prefix if present in the text.
let innerPath = qualName.text.trim();
innerPath = innerPath.replace(/^(?:function|const)\s+/i, '').trim();
if (innerPath === '') return null;
const source = prefix !== '' ? `${prefix}\\${innerPath}` : innerPath;
// Strategy 1: explicit alias_clause wrapper (older grammar versions).
const aliasClause = findNamedChild(clause, 'alias_clause');
if (aliasClause !== null) {
const aliasName = findNamedChild(aliasClause, 'name') ?? aliasClause.firstNamedChild;
const alias = aliasName?.text.trim() ?? '';
if (alias === '') return null;
return {
kind: 'alias',
source,
name: alias,
alias,
atNode: clause,
};
}
// Strategy 2: bare sibling `name` node after the qualified_name (tree-sitter-php ≥ 0.22).
if (clause.namedChildCount >= 2) {
const lastChild = clause.namedChild(clause.namedChildCount - 1);
if (lastChild !== null && lastChild !== qualName && lastChild.type === 'name') {
const alias = lastChild.text.trim();
if (alias !== '') {
return {
kind: 'alias',
source,
name: alias,
alias,
atNode: clause,
};
}
}
}
return {
kind: qualifier,
source,
name: lastSegment(innerPath),
atNode: clause,
};
}
// ── CaptureMatch builder ───────────────────────────────────────────────────
function buildImportMatch(stmtNode: SyntaxNode, spec: PhpImportSpec): CaptureMatch {
const m: Record<string, Capture> = {
'@import.statement': nodeToCapture('@import.statement', stmtNode),
'@import.kind': syntheticCapture('@import.kind', spec.atNode, spec.kind),
'@import.source': syntheticCapture('@import.source', spec.atNode, spec.source),
'@import.name': syntheticCapture('@import.name', spec.atNode, spec.name),
};
if (spec.alias !== undefined) {
m['@import.alias'] = syntheticCapture('@import.alias', spec.atNode, spec.alias);
}
return m;
}
// ── Helpers ────────────────────────────────────────────────────────────────
/** Last backslash-separated segment: `Foo\Bar\Baz` → `Baz`. */
function lastSegment(path: string): string {
const parts = path.split('\\').filter(Boolean);
return parts[parts.length - 1] ?? path;
}
/** Find the first named child with a given node type. */
function findNamedChild(node: SyntaxNode, type: string): SyntaxNode | null {
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child !== null && child.type === type) return child;
}
return null;
}

View file

@ -0,0 +1,140 @@
/**
* Adapter from `(ParsedImport, WorkspaceIndex)` concrete file path.
*
* Delegates to the existing `resolvePhpImportInternal` (PSR-4 via
* composer.json + suffix matching fallback). The `WorkspaceIndex` is
* opaque at this layer; consumers wire a `PhpResolveContext` shape
* carrying `fromFile` + `allFilePaths`.
*
* `loadPhpComposerConfig` is the `ScopeResolver.loadResolutionConfig`
* implementation it loads `composer.json` once per workspace pass and
* threads the parsed config into every subsequent `resolveImportTarget`
* call via the opaque `resolutionConfig` parameter.
*
* Returning `null` lets the finalize algorithm mark the edge as
* `linkStatus: 'unresolved'`.
*/
import type { ParsedImport, WorkspaceIndex } from 'gitnexus-shared';
import { resolvePhpImportInternal } from '../../import-resolvers/php.js';
import type { ComposerConfig } from '../../language-config.js';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
export interface PhpResolveContext {
readonly fromFile: string;
readonly allFilePaths: ReadonlySet<string>;
}
// ─── loadResolutionConfig ──────────────────────────────────────────────────
/**
* Load and parse `composer.json` from the repo root. Returns a
* `ComposerConfig` object (PSR-4 namespace directory mappings) or
* `null` when no `composer.json` is present or it cannot be parsed.
*
* The result is threaded into each `resolvePhpImportInternal` call as
* the `composerConfig` argument.
*/
export function loadPhpComposerConfig(repoPath: string): ComposerConfig | null {
try {
const composerPath = join(repoPath, 'composer.json');
const raw = readFileSync(composerPath, 'utf8');
const parsed = JSON.parse(raw) as unknown;
if (typeof parsed !== 'object' || parsed === null) return null;
const composer = parsed as Record<string, unknown>;
const autoload = composer['autoload'] as Record<string, unknown> | undefined;
if (autoload === undefined) return null;
const psr4Raw = (autoload['psr-4'] ?? {}) as Record<string, string | string[]>;
const psr4 = new Map<string, string>();
for (const [ns, dirs] of Object.entries(psr4Raw)) {
// namespace prefix ends with `\` — keep as-is; resolver strips it
const normalizedNs = ns.replace(/\\$/, '');
const dir = Array.isArray(dirs) ? dirs[0] : dirs;
if (typeof dir === 'string') {
// Normalize directory path (strip trailing slash)
const normalizedDir = dir.replace(/\/+$/, '');
psr4.set(normalizedNs, normalizedDir);
}
}
return { psr4 };
} catch {
return null;
}
}
// ─── resolvePhpImportTarget ────────────────────────────────────────────────
/**
* LanguageProvider-shaped adapter: `(ParsedImport, WorkspaceIndex) → string | null`.
*
* The `WorkspaceIndex` is `unknown` in the shared contract. The scope-resolution
* orchestrator hands us a `PhpResolveContext`-shaped object; narrow structurally
* rather than via a cast chain so unexpected shapes return `null` cleanly.
*/
export function resolvePhpImportTarget(
parsedImport: ParsedImport,
workspaceIndex: WorkspaceIndex,
): string | null {
const ctx = workspaceIndex as PhpResolveContext | undefined;
if (
ctx === undefined ||
typeof (ctx as { fromFile?: unknown }).fromFile !== 'string' ||
!((ctx as { allFilePaths?: unknown }).allFilePaths instanceof Set)
) {
return null;
}
if (parsedImport.kind === 'dynamic-unresolved') return null;
if (parsedImport.targetRaw === null || parsedImport.targetRaw === '') return null;
const allFiles = ctx.allFilePaths as Set<string>;
const normalizedFileList = [...allFiles].map((f) => f.replace(/\\/g, '/'));
const allFileList = [...allFiles];
return resolvePhpImportInternal(
parsedImport.targetRaw,
null, // composerConfig not available through LanguageProvider path
allFiles,
normalizedFileList,
allFileList,
undefined,
);
}
/**
* ScopeResolver-shaped adapter: `(targetRaw, fromFile, allFilePaths, resolutionConfig?) → string | null`.
*
* Used inside `scope-resolver.ts`. Accepts the optional `resolutionConfig`
* (a `ComposerConfig | null` loaded once per workspace by
* `loadPhpComposerConfig`) and threads it into `resolvePhpImportInternal`.
*/
export function resolvePhpImportTargetInternal(
targetRaw: string,
_fromFile: string,
allFilePaths: ReadonlySet<string>,
resolutionConfig?: unknown,
): string | null {
if (targetRaw === '') return null;
const composerConfig =
resolutionConfig !== undefined && resolutionConfig !== null
? (resolutionConfig as ComposerConfig)
: null;
const allFiles = allFilePaths as Set<string>;
const normalizedFileList = [...allFiles].map((f) => f.replace(/\\/g, '/'));
const allFileList = [...allFiles];
return resolvePhpImportInternal(
targetRaw,
composerConfig,
allFiles,
normalizedFileList,
allFileList,
undefined,
);
}

View file

@ -0,0 +1,73 @@
/**
* PHP scope-resolution hooks (RFC #909 Ring 3 LANG-php, #938).
*
* Public API barrel. Consumers should import from this file rather than
* the individual modules.
*
* Module layout (each file is a single concern):
*
* - `query.ts` tree-sitter query + lazy parser/query singletons
* - `captures.ts` `emitPhpScopeCaptures` orchestrator
* - `import-decomposer.ts` each `namespace_use_declaration` ParsedImport captures
* - `interpret.ts` capture-match `ParsedImport` / `ParsedTypeBinding`
* - `simple-hooks.ts` small/no-op hooks made explicit
* - `receiver-binding.ts` synthesize `$this` / `parent` type-bindings on
* instance-method entry
* - `merge-bindings.ts` PHP `use` precedence (local > import > wildcard)
* - `arity.ts` PHP arity compatibility (variadic, defaults)
* - `arity-metadata.ts` synthesize arity metadata from declarations
* - `import-target.ts` `(ParsedImport, WorkspaceIndex) → file path` adapter
* wrapping `resolvePhpImportInternal` (PSR-4 + composer.json)
* - `scope-resolver.ts` `ScopeResolver` registered in `SCOPE_RESOLVERS`
* - `cache-stats.ts` PROF_SCOPE_RESOLUTION cache hit/miss counters
*
* ## Known limitations
*
* The PHP registry-primary path intentionally does NOT resolve the following.
* Each is a conscious trade-off at migration time.
*
* 1. **Trait `$this` using-class binding** for methods defined in a
* trait, `$this` is synthesized as a binding to the trait itself.
* Resolving `$this` to the actual using-class type requires cross-file
* analysis of all `use TraitName;` declarations in class bodies.
* Deferred to a follow-up; trait method resolution falls back to the
* trait scope.
*
* 2. **Anonymous classes** `new class extends Foo { }` have no stable
* class name and are skipped by receiver-binding synthesis. The class
* body is still scoped; member lookups inside it will fall back to
* free-call resolution.
*
* 3. **Dynamic property/method access** `$obj->{$name}()` and
* `$$varName` are not followed. The dynamic receiver is ignored and
* the call falls through to the shared free-call resolver.
*
* 4. **Magic methods** `__get`, `__set`, `__call`, `__callStatic` are
* not modeled as virtual dispatch; they appear as regular method
* declarations in the graph but calls that would route through them
* at runtime are not distinguished.
*
* 5. **Laravel facade magic** `App::make(...)`, `Cache::get(...)` etc.
* resolve statically to the Facade class rather than the underlying
* bound implementation. Deferred to a Laravel-specific plugin.
*
* 6. **Intersection types in parameters** `T&U $param` takes the first
* named part (`T`). This matches the legacy type-extractor's behavior.
*
* Shadow-harness corpus parity is the authoritative signal for which of
* these matter in practice. The CI parity gate blocks any PR that regresses
* either the legacy or registry-primary run of
* `test/integration/resolvers/php.test.ts`.
*/
export { emitPhpScopeCaptures } from './captures.js';
export { getPhpCaptureCacheStats, resetPhpCaptureCacheStats } from './cache-stats.js';
export { interpretPhpImport, interpretPhpTypeBinding } from './interpret.js';
export { phpMergeBindings } from './merge-bindings.js';
export { phpArityCompatibility } from './arity.js';
export { resolvePhpImportTarget, type PhpResolveContext } from './import-target.js';
export { phpBindingScopeFor, phpImportOwningScope, phpReceiverBinding } from './simple-hooks.js';
// NOTE: phpScopeResolver is intentionally NOT re-exported from this barrel.
// Importing it here would create a circular dependency:
// php.ts → php/index.js → php/scope-resolver.js → ../php.js
// Registry and other consumers must import directly from './php/scope-resolver.js'.

View file

@ -0,0 +1,250 @@
/**
* Capture-match semantic-shape interpreters for PHP.
*
* - `interpretPhpImport` `ParsedImport`
* - `interpretPhpTypeBinding` `ParsedTypeBinding`
*
* Import matches arrive pre-decomposed by `emitPhpScopeCaptures` (one
* CaptureMatch per logical import, with synthesized `@import.kind /
* source / name / alias` markers). Type-binding matches arrive from
* the raw query captures each `@type-binding.*` anchor carries
* `@type-binding.name` + `@type-binding.type`.
*/
import type { CaptureMatch, ParsedImport, ParsedTypeBinding, TypeRef } from 'gitnexus-shared';
// ─── interpretImport ──────────────────────────────────────────────────────
export function interpretPhpImport(captures: CaptureMatch): ParsedImport | null {
const kindCap = captures['@import.kind'];
const sourceCap = captures['@import.source'];
const nameCap = captures['@import.name'];
const aliasCap = captures['@import.alias'];
const kind = kindCap?.text;
if (kind === undefined || sourceCap === undefined) return null;
const source = sourceCap.text.trim();
if (source === '') return null;
switch (kind) {
case 'namespace': {
// `use Foo\Bar;` — PHP `use` is a NAMED import (binds the class
// `Bar`, not the namespace `Foo`). This differs from C# `using`,
// which is a true namespace import. Producing 'named' here makes
// `new Bar()` resolve to the imported class def.
const localName = nameCap?.text.trim() ?? lastSegment(source);
return {
kind: 'named',
localName,
importedName: localName,
targetRaw: source,
};
}
case 'alias': {
// `use Foo\Bar as Baz;`
if (aliasCap === undefined) return null;
const alias = aliasCap.text.trim();
if (alias === '') return null;
const importedName = lastSegment(source);
return {
kind: 'alias',
localName: alias,
importedName,
alias,
targetRaw: source,
};
}
case 'function': {
// `use function Foo\bar;` — treat as named import; importedName is
// the function name (last segment). targetRaw is the full path.
const localName = nameCap?.text.trim() ?? lastSegment(source);
return {
kind: 'named',
localName,
importedName: localName,
targetRaw: source,
};
}
case 'const': {
// `use const Foo\BAR;` — same shape as function.
const localName = nameCap?.text.trim() ?? lastSegment(source);
return {
kind: 'named',
localName,
importedName: localName,
targetRaw: source,
};
}
default:
return null;
}
}
// ─── interpretTypeBinding ─────────────────────────────────────────────────
export function interpretPhpTypeBinding(captures: CaptureMatch): ParsedTypeBinding | null {
const nameCap = captures['@type-binding.name'];
const typeCap = captures['@type-binding.type'];
if (nameCap === undefined || typeCap === undefined) return null;
// Determine source from anchor captures. Order: most-specific first.
let source: TypeRef['source'] = 'parameter-annotation';
if (captures['@type-binding.self'] !== undefined) source = 'self';
else if (captures['@type-binding.constructor'] !== undefined) source = 'constructor-inferred';
else if (captures['@type-binding.annotation'] !== undefined) source = 'annotation';
else if (captures['@type-binding.alias'] !== undefined) source = 'assignment-inferred';
else if (captures['@type-binding.return'] !== undefined) source = 'return-annotation';
let rawType: string | null;
if (source === 'assignment-inferred') {
// `@type-binding.alias` captures cover several assignment RHS shapes:
// - `$alias = $u` → rawType = '$u' (variable alias)
// - `$u = getUser()` → rawType = 'getUser' (callable alias)
// - `$u = new User()` → rawType = 'User' (constructor — via @type-binding.constructor; handled below)
// - `$role = UserRole::Viewer` → rawType = 'UserRole' (enum/class constant)
//
// For variable aliases (`$u`), `normalizePhpType` returns null because
// `$` is not a word character. We must preserve the raw `$`-prefixed name
// so `followChainedRef` can walk the chain `$alias → $u → User`.
// For callable/class names, `normalizePhpType` strips qualifiers correctly.
const rawText = typeCap.text.trim();
if (rawText.startsWith('$')) {
// Variable alias: keep as-is for chain-following.
rawType = rawText;
} else {
rawType = normalizePhpType(rawText);
}
} else {
// All other sources: strip PHP type decoration to get the simple class name:
// ?User → User (nullable prefix)
// User|null → User (union with null/false/void)
// User&Loggable → User (intersection — take first meaningful)
// Collection<User> → User (PHPDoc generic wrapper)
// User[] → User (array suffix)
// \App\Models\User → User (backslash qualifier)
rawType = normalizePhpType(typeCap.text.trim());
}
if (rawType === null) return null;
// PHP variable names include the `$` sigil (e.g. `$user`). Most
// bindings keep it because they are looked up via the variable
// (`$user->method()` finds binding `$user`). Property field bindings
// are different: `$user->address` looks up `address` (no sigil) on
// the User class. Property declarations carry source `'annotation'`,
// so we strip the leading `$` for that source only.
let boundName = nameCap.text.trim();
if (source === 'annotation' && boundName.startsWith('$')) {
boundName = boundName.slice(1);
}
return { boundName, rawTypeName: rawType, source };
}
// ─── Type normalization ───────────────────────────────────────────────────
/**
* Normalize a PHP type string to a simple class identifier, or `null`
* when the type is uninformative (primitive, void, mixed, self, etc.).
*
* Rules applied in order:
* 1. Strip nullable prefix `?`
* 2. Split on `|` (union) keep only if exactly one non-null part
* 3. Take first part of `&` intersection
* 4. Strip array suffix `[]`
* 5. Strip generic wrapper `Collection<User>` `User`
* 6. Canonicalize leading backslash off: `\App\Models\User` `App\Models\User`
* 7. Reject PHP primitive / pseudo types
*
* The qualified form is preserved on `TypeRef.rawName` so downstream PHP
* receiver resolution can distinguish `\App\Other\User` from a same-simple-name
* `User` reachable via `use`. Without this, fully-qualified type hints collapse
* to ambiguous simple names and resolve against the caller's scope chain
* instead of the explicit target the source named (Codex PR #1497 review,
* finding 1).
*/
export function normalizePhpType(raw: string): string | null {
// 1. Strip nullable prefix
let type = raw.startsWith('?') ? raw.slice(1).trim() : raw;
// 2. Union type — keep only if one non-null/false/void part remains
if (type.includes('|')) {
const parts = type
.split('|')
.map((p) => p.trim())
.filter((p) => p !== 'null' && p !== 'false' && p !== 'void' && p !== 'mixed' && p !== '');
if (parts.length !== 1) return null;
type = parts[0];
}
// 3. Intersection type — take the first part
if (type.includes('&')) {
const first = type.split('&')[0].trim();
if (first === '') return null;
type = first;
}
// 4. Strip array suffix
if (type.endsWith('[]')) type = type.slice(0, -2).trim();
// 5. Strip single-arg generic wrapper: Collection<User> → User
// Qualified inner types (Collection<\App\Models\User>) survive — the
// capture group preserves whatever the writer named.
const genericMatch = type.match(/^\w[\w\\]*\s*<([^,<>]+)>$/);
if (genericMatch) {
type = genericMatch[1].trim();
}
// 6. Canonicalize leading backslash off — keep the qualified path intact.
// `\App\Models\User` → `App\Models\User`. `App\Models\User` → unchanged.
// Unqualified `User` stays as `User`. The qualified form is the lookup
// key into the workspace QualifiedNameIndex (PHP defs are indexed by
// namespace-joined qualifiedName); the leading-backslash distinction in
// source is only an "absolute path" anchor, not part of the canonical key.
if (type.startsWith('\\')) type = type.replace(/^\\+/, '');
// 7. Reject primitives / pseudo-types
if (isPrimitiveOrPseudo(type)) return null;
// Must be a (possibly qualified) PHP identifier — segments of word chars
// separated by single backslashes. Empty segments (consecutive backslashes,
// trailing backslash) are rejected.
if (!/^\w+(?:\\\w+)*$/.test(type)) return null;
return type;
}
const PHP_PRIMITIVE_TYPES = new Set([
'int',
'integer',
'float',
'double',
'string',
'bool',
'boolean',
'array',
'object',
'callable',
'iterable',
'null',
'void',
'never',
'mixed',
'false',
'true',
'self',
'static',
'parent',
]);
function isPrimitiveOrPseudo(type: string): boolean {
return PHP_PRIMITIVE_TYPES.has(type.toLowerCase());
}
/** Last backslash-separated segment: `Foo\Bar\Baz` → `Baz`. */
function lastSegment(path: string): string {
const parts = path.split('\\').filter(Boolean);
return parts[parts.length - 1] ?? path;
}

View file

@ -0,0 +1,51 @@
/**
* PHP shadowing precedence for the `mergeBindings` hook.
*
* Tier ranking (lower wins in shadowing):
*
* - 0: `local` a class member, method, local variable, or parameter
* declared in this scope.
* - 1: `import` / `namespace` / `reexport` `use Foo\Bar;`,
* `use Foo\Bar as Baz;`, `use function`, `use const`.
* All use-statement flavors that introduce a name sit at this tier.
* - 2: `wildcard` grouped uses / wildcard imports (deferred; mapped
* here for completeness).
*
* Within a surviving tier we de-dup by `DefId`, last-write-wins so a
* `use` re-declared further down the file cleanly replaces the earlier
* binding.
*/
import type { BindingRef } from 'gitnexus-shared';
const TIER_LOCAL = 0;
const TIER_IMPORT = 1;
const TIER_WILDCARD = 2;
const TIER_UNKNOWN = 3;
function tierOf(b: BindingRef): number {
switch (b.origin) {
case 'local':
return TIER_LOCAL;
case 'reexport':
case 'import':
case 'namespace':
return TIER_IMPORT;
case 'wildcard':
return TIER_WILDCARD;
default:
return TIER_UNKNOWN;
}
}
export function phpMergeBindings(bindings: readonly BindingRef[]): readonly BindingRef[] {
if (bindings.length === 0) return bindings;
let bestTier = Number.POSITIVE_INFINITY;
for (const b of bindings) bestTier = Math.min(bestTier, tierOf(b));
const survivors = bindings.filter((b) => tierOf(b) === bestTier);
const seen = new Map<string, BindingRef>();
for (const b of survivors) seen.set(b.def.nodeId, b);
return [...seen.values()];
}

View file

@ -0,0 +1,335 @@
/**
* PHP same-namespace cross-file visibility.
*
* In PHP, every class declared in `namespace Foo\Bar` is visible to all
* other files in the same namespace WITHOUT an explicit `use` statement.
* Without this pass, `Service.php` (namespace `App\Services`) can't see
* `User` declared in `Models.php` (namespace `App\Models`) unless
* `UserService.php` has an explicit `use App\Models\User` statement.
*
* More importantly, A.php (namespace `App\Models`) can return `Greeting`
* (same namespace `App\Models`) without importing it, and the compound-
* receiver resolver needs to find `Greeting` as a class binding in the
* scope chain.
*
* Implementation mirrors C#'s `namespace-siblings.ts`:
* 1. Extract the declared namespace from each PHP file's source.
* 2. Group class-like defs by namespace.
* 3. Inject sibling class defs into each file's Module scope's
* `bindingAugmentations` with `origin: 'namespace'`.
* 4. Also mirror return-type bindings from same-namespace siblings
* so cross-file chain-follow finds return types without explicit imports.
*
* Uses the PHP tree-sitter parser (via the lazy singleton in `query.ts`)
* to extract namespace declarations same AST that `extractParsedFile`
* already parsed, reused via `treeCache` to avoid double-parsing.
*/
import type { BindingRef, ParsedFile, Scope, ScopeId, SymbolDefinition } from 'gitnexus-shared';
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
import { getPhpParser } from './query.js';
import { getTreeSitterBufferSize } from '../../constants.js';
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
// ─── PHP file structure extraction ──────────────────────────────────────────
interface PhpFileStructure {
/** The declared namespace (backslash-separated), or '' for global namespace. */
readonly namespace: string;
}
type PhpTree = ReturnType<ReturnType<typeof getPhpParser>['parse']>;
/**
* Extract the declared namespace from a PHP file's source.
* Uses the cached AST tree when available to avoid re-parsing.
*/
function extractPhpFileStructure(content: string, cachedTree: unknown): PhpFileStructure {
const tree =
(cachedTree as PhpTree | undefined) ??
parseSourceSafe(getPhpParser(), content, undefined, {
bufferSize: getTreeSitterBufferSize(content),
});
// Walk top-level nodes looking for namespace_definition.
// PHP files have at most one namespace declaration (PSR-4 convention).
// `namespace_definition` has a `name:` field of type `namespace_name`.
const root = tree.rootNode;
for (let i = 0; i < root.namedChildCount; i++) {
const child = root.namedChild(i);
if (child === null) continue;
if (child.type === 'namespace_definition') {
const nameNode = child.childForFieldName('name');
if (nameNode !== null) {
return { namespace: nameNode.text };
}
}
}
return { namespace: '' };
}
// ─── Augmentation bucket helper ─────────────────────────────────────────────
function getAugmentationBucket(
augmentations: Map<ScopeId, Map<string, BindingRef[]>>,
scopeId: ScopeId,
name: string,
): BindingRef[] {
let scopeBindings = augmentations.get(scopeId);
if (scopeBindings === undefined) {
scopeBindings = new Map<string, BindingRef[]>();
augmentations.set(scopeId, scopeBindings);
}
let bucket = scopeBindings.get(name);
if (bucket === undefined) {
bucket = [];
scopeBindings.set(name, bucket);
}
return bucket;
}
function isClassLikeDef(def: SymbolDefinition): boolean {
return (
def.type === 'Class' ||
def.type === 'Interface' ||
def.type === 'Struct' ||
def.type === 'Enum' ||
def.type === 'Trait'
);
}
// ─── Public entry point ──────────────────────────────────────────────────────
export interface PhpSiblingInputs {
readonly fileContents: ReadonlyMap<string, string>;
readonly treeCache?: { get(filePath: string): unknown };
}
/**
* Side-channel cache populated by `populatePhpNamespaceSiblings` so that
* later visibility-check hooks (e.g., `isCallableVisibleFromCaller`) can
* look up a file's PHP namespace without re-parsing. Cleared at the start
* of every populate run so stale entries don't leak across resolutions.
*/
const namespaceByFilePath = new Map<string, string>();
/**
* Read the cached PHP namespace for a given filePath. Returns `''` (global)
* when the file has no namespace_definition or hasn't been processed yet.
* Callers should only consult this AFTER either `populatePhpClassQualifiedNames`
* or `populatePhpNamespaceSiblings` has run for the current resolution.
*/
export function getPhpNamespaceForFile(filePath: string): string {
return namespaceByFilePath.get(filePath) ?? '';
}
/**
* Inject same-namespace class defs and return-type bindings into each
* PHP file's Module scope's `bindingAugmentations`. This makes classes
* in the same PHP namespace visible to each other without explicit `use`
* statements, mirroring PHP's actual runtime behavior.
*
* Uses `origin: 'namespace'` so `phpMergeBindings` tiers it below
* explicit `use` imports (`origin: 'import'`) and local declarations.
*/
export function populatePhpNamespaceSiblings(
parsedFiles: readonly ParsedFile[],
indexes: ScopeResolutionIndexes,
inputs: PhpSiblingInputs,
): void {
// Step 1: extract namespace structure for each file. Also seed the
// side-channel cache used by visibility-check hooks downstream.
namespaceByFilePath.clear();
const structureByFile = new Map<string, PhpFileStructure>();
for (const parsed of parsedFiles) {
const content = inputs.fileContents.get(parsed.filePath);
if (content === undefined) continue;
const cachedTree = inputs.treeCache?.get(parsed.filePath);
const struct = extractPhpFileStructure(content, cachedTree);
structureByFile.set(parsed.filePath, struct);
namespaceByFilePath.set(parsed.filePath, struct.namespace);
}
// Step 2: group class-like defs and module scopes by namespace.
interface NamespaceBucket {
readonly scopes: { filePath: string; scopeId: ScopeId; scope: Scope }[];
readonly classDefs: SymbolDefinition[];
}
const buckets = new Map<string, NamespaceBucket>();
const getBucket = (ns: string): NamespaceBucket => {
let b = buckets.get(ns);
if (b === undefined) {
b = { scopes: [], classDefs: [] };
buckets.set(ns, b);
}
return b;
};
for (const parsed of parsedFiles) {
const struct = structureByFile.get(parsed.filePath);
if (struct === undefined) continue;
const ns = struct.namespace;
const bucket = getBucket(ns);
// Register the file's module scope in the bucket.
const moduleScope = parsed.scopes.find((s) => s.kind === 'Module');
if (moduleScope !== undefined) {
bucket.scopes.push({
filePath: parsed.filePath,
scopeId: moduleScope.id,
scope: moduleScope,
});
}
// Collect class-like defs declared at the top-level of this file
// (defs in Class or Module scopes, excluding nested inner classes).
for (const scope of parsed.scopes) {
if (scope.kind !== 'Class') continue;
// Only top-level class scopes (parent is Module or Namespace scope).
if (scope.parent === null) continue;
const parentScope = parsed.scopes.find((s) => s.id === scope.parent);
if (
parentScope === undefined ||
(parentScope.kind !== 'Module' && parentScope.kind !== 'Namespace')
) {
continue;
}
for (const def of scope.ownedDefs) {
if (isClassLikeDef(def)) {
bucket.classDefs.push(def);
break; // one class-like per scope
}
}
}
}
const augmentations = indexes.bindingAugmentations as Map<ScopeId, Map<string, BindingRef[]>>;
// Step 3: For each namespace bucket, inject sibling class bindings
// into every file's Module scope (that is NOT the declaring file).
for (const [, bucket] of buckets) {
// Build name → def map (simple name of qualifiedName).
const defsByName = new Map<string, SymbolDefinition[]>();
for (const def of bucket.classDefs) {
const q = def.qualifiedName ?? '';
const simpleName = q.includes('.')
? q.slice(q.lastIndexOf('.') + 1)
: q.includes('\\')
? q.slice(q.lastIndexOf('\\') + 1)
: q;
if (simpleName === '') continue;
const arr = defsByName.get(simpleName) ?? [];
arr.push(def);
defsByName.set(simpleName, arr);
}
for (const { filePath, scopeId, scope } of bucket.scopes) {
for (const [name, defs] of defsByName) {
// Skip if already locally declared (origin: 'local' wins).
const local = scope.bindings.get(name);
if (local !== undefined && local.some((b) => b.origin === 'local')) continue;
for (const def of defs) {
if (def.filePath === filePath) continue; // don't self-inject
const arr = getAugmentationBucket(augmentations, scopeId, name);
if (arr.some((b) => b.def.nodeId === def.nodeId)) continue;
arr.push({ def, origin: 'namespace' });
}
}
}
}
// Step 3b: Inject fully-qualified-name bindings into every PHP file's
// Module scope. PHP `\App\Models\User` (leading-backslash FQN) and
// `App\Models\User` (already-qualified relative) on a parameter or
// typed receiver must resolve to the exact namespace-qualified class
// regardless of which simple-name `User` the caller's `use` imports
// shadowed. The shared `findClassBindingInScope` scope-chain walk
// consumes these augmentations via `lookupBindingsAt`, so adding the
// qualified key on every file's module scope routes FQN-receivers to
// the right def. Codex PR #1497 review, finding 1.
//
// Cost: O(PHP files × class-like defs in the workspace) augmentation
// entries. Bounded and acceptable in practice — typical PHP projects
// have hundreds of files and classes, not tens of thousands.
for (const parsed of parsedFiles) {
const moduleScope = parsed.scopes.find((s) => s.kind === 'Module');
if (moduleScope === undefined) continue;
const moduleScopeId = moduleScope.id;
for (const [ns, bucket] of buckets) {
if (ns === '') continue; // global-namespace classes have no qualified form to register
for (const def of bucket.classDefs) {
const q = def.qualifiedName ?? '';
const simpleName = q.includes('\\') ? q.slice(q.lastIndexOf('\\') + 1) : q;
if (simpleName === '') continue;
const fqn = `${ns}\\${simpleName}`;
const arr = getAugmentationBucket(augmentations, moduleScopeId, fqn);
if (arr.some((b) => b.def.nodeId === def.nodeId)) continue;
arr.push({ def, origin: 'namespace' });
}
}
}
// Step 4: Mirror return-type bindings from same-namespace sibling files.
// This enables chain-follow like `$c->greet()->save()` where `greet()`
// returns `Greeting` (declared in A.php, same namespace) and `Greeting`
// isn't imported in the calling file. Without this, the compound-receiver
// resolver can't resolve `Greeting` as a class binding in the importer's
// scope chain.
//
// Additionally, mirror from files that are imported via `use` (different
// namespace) so return types from dependencies are chain-followable too.
for (const parsed of parsedFiles) {
const moduleScope = parsed.scopes.find((s) => s.kind === 'Module');
if (moduleScope === undefined) continue;
const moduleTypeBindings = moduleScope.typeBindings as Map<
string,
import('gitnexus-shared').TypeRef
>;
const struct = structureByFile.get(parsed.filePath);
const ownNs = struct?.namespace ?? '';
// Collect namespaces accessible from this file:
// 1. Own namespace (same-ns siblings)
// 2. Namespaces of directly imported files (via parsedImports → targetRaw → PSR-4 namespace)
const accessibleFiles = new Set<string>();
// Same-namespace siblings.
const sameBucket = buckets.get(ownNs);
if (sameBucket !== undefined) {
for (const { filePath } of sameBucket.scopes) {
if (filePath !== parsed.filePath) accessibleFiles.add(filePath);
}
}
// Files directly imported by this file (finalized import edges).
const ownModuleScopeBindings = indexes.bindings.get(moduleScope.id);
if (ownModuleScopeBindings !== undefined) {
for (const [, refs] of ownModuleScopeBindings) {
for (const ref of refs) {
if (ref.origin === 'import' || ref.origin === 'namespace') {
const importFilePath = ref.def.filePath;
if (importFilePath !== parsed.filePath) {
accessibleFiles.add(importFilePath);
}
}
}
}
}
// Mirror return-type bindings from accessible files.
for (const srcFilePath of accessibleFiles) {
const srcParsed = parsedFiles.find((p) => p.filePath === srcFilePath);
if (srcParsed === undefined) continue;
const srcModuleScope = srcParsed.scopes.find((s) => s.kind === 'Module');
if (srcModuleScope === undefined) continue;
for (const [boundName, typeRef] of srcModuleScope.typeBindings) {
if (moduleTypeBindings.has(boundName)) continue;
moduleTypeBindings.set(boundName, typeRef);
}
}
}
}

View file

@ -0,0 +1,332 @@
/**
* Tree-sitter query for PHP scope captures (RFC #909 Ring 3 LANG-php).
*
* Captures the structural skeleton the generic scope-resolution pipeline
* consumes: scopes (program/namespace/class/function), declarations
* (class-likes, method-likes, properties, variables), imports
* (namespace_use_declaration), type bindings (parameter annotations,
* property types, constructor-inferred locals, return types), and
* references (call sites, member writes).
*
* PHP specifics that shape this query:
*
* - `namespace_use_declaration` is an import only at top level / inside
* namespace blocks. Class-body `use_declaration` (trait-use) is a
* different node type and is NOT captured here.
*
* - `object_creation_expression` has `name` and `qualified_name` as
* direct children (no wrapping node).
*
* - `method_declaration` exposes a `return_type:` named field containing
* a `type` node, which may be `named_type`, `optional_type`, etc.
*
* - `property_element` has a `name:` field of type `variable_name`.
*
* - `variable_name` nodes always include the `$` sigil in their text.
*
* Exposes lazy `Parser` and `Query` singletons so callers don't pay
* tree-sitter init cost per file.
*/
import Parser from 'tree-sitter';
import Php from 'tree-sitter-php';
// tree-sitter-php exports `{ php, php_only, html }` in recent versions, or the
// language directly in older versions.
//
// IMPORTANT: must match the grammar used by the central parse phase
// (`src/core/tree-sitter/parser-loader.ts` line: `[SupportedLanguages.PHP]: PHP.php_only`).
// Using a different grammar variant causes tree-sitter to throw when running
// a query built against grammar A on a tree parsed by grammar B — this error
// is swallowed by `scope-extractor-bridge.ts`, producing silent empty results.
const Php_typed = Php as unknown as { php_only?: unknown; php?: unknown };
const PHP_LANG = Php_typed.php_only ?? Php_typed.php ?? Php;
const PHP_SCOPE_QUERY = `
;; Scopes
(program) @scope.module
;; Both block-scoped and statement-scoped namespace declarations.
(namespace_definition) @scope.namespace
(class_declaration) @scope.class
(interface_declaration) @scope.class
(trait_declaration) @scope.class
(enum_declaration) @scope.class
(method_declaration) @scope.function
(function_definition) @scope.function
(anonymous_function) @scope.function
(arrow_function) @scope.function
;; Declarations types
(class_declaration
name: (name) @declaration.name) @declaration.class
(interface_declaration
name: (name) @declaration.name) @declaration.interface
(trait_declaration
name: (name) @declaration.name) @declaration.trait
(enum_declaration
name: (name) @declaration.name) @declaration.enum
;; Declarations methods / functions / constructors
(method_declaration
name: (name) @declaration.name) @declaration.method
(function_definition
name: (name) @declaration.name) @declaration.function
;; Declarations properties
;; PHP 7.4+ typed property: private UserRepo $repo;
;; property_element has name: (variable_name) field.
;; Emits BOTH a declaration (so SemanticModel registers the property) AND a type-binding.
(property_declaration
type: (_) @type-binding.type
(property_element
name: (variable_name) @type-binding.name)) @type-binding.annotation
(property_declaration
type: (_)
(property_element
name: (variable_name) @declaration.name)) @declaration.property
;; Untyped property: public $id; capture as plain declaration.
(property_declaration
(property_element
name: (variable_name) @declaration.name)) @declaration.variable
;; Imports namespace_use_declaration
;;
;; Captures ALL forms: plain, alias, function/const qualifiers, and grouped.
;; The import-decomposer in captures.ts fans out grouped uses.
;;
;; NOTE: class-body use_declaration = trait-use, NOT an import.
;; Only namespace_use_declaration (top-level / namespace scope) is an import.
(namespace_use_declaration) @import.statement
;; Type bindings parameters
;; simple_parameter with a type hint: function f(User $u)
;; type field is a 'type' supertype (named_type, optional_type, union_type, etc.)
(simple_parameter
type: (_) @type-binding.type
name: (variable_name) @type-binding.name) @type-binding.parameter
;; property_promotion_parameter: function __construct(private User $u)
;; Emits type-binding so the constructor body can resolve $u as the typed param.
(property_promotion_parameter
type: (_) @type-binding.type
name: (variable_name) @type-binding.name) @type-binding.parameter
;; Also emit a @type-binding.annotation for the promoted parameter so that
;; phpBindingScopeFor can hoist it to the Class scope (stripping the $ sigil).
;; This enables compound-receiver resolution: $user->address->save() resolves
;; address Address via the Class scope's typeBindings.
;; The @type-binding.parameter above stays for constructor-body resolution ($address).
(property_promotion_parameter
type: (_) @type-binding.type
name: (variable_name) @type-binding.name) @type-binding.annotation
;; Also emit a @declaration.property so SemanticModel registers the promoted
;; parameter as a class-owned property (enabling $obj->propName lookups).
(property_promotion_parameter
name: (variable_name) @declaration.name) @declaration.property
;; Type bindings local assignment: $u = new User()
;; new ClassName() name is a direct child of object_creation_expression
(assignment_expression
left: (variable_name) @type-binding.name
right: (object_creation_expression
(name) @type-binding.type)) @type-binding.constructor
;; new Foo\Bar\ClassName() qualified_name wraps name
(assignment_expression
left: (variable_name) @type-binding.name
right: (object_creation_expression
(qualified_name
(name) @type-binding.type))) @type-binding.constructor
;; Type bindings $alias = $u (identifier alias)
(assignment_expression
left: (variable_name) @type-binding.name
right: (variable_name) @type-binding.type) @type-binding.alias
;; Type bindings $u = factory() (free call return alias)
(assignment_expression
left: (variable_name) @type-binding.name
right: (function_call_expression
function: (name) @type-binding.type)) @type-binding.alias
;; Type bindings $u = $svc->getUser() (method call return alias)
(assignment_expression
left: (variable_name) @type-binding.name
right: (member_call_expression
name: (name) @type-binding.type)) @type-binding.alias
;; Type bindings method return type
;; method_declaration exposes return_type: field (type node supertype).
;; named_type wraps the class name: function getUser(): User
(method_declaration
name: (name) @type-binding.name
return_type: (named_type
(name) @type-binding.type)) @type-binding.return
;; nullable return type via optional_type: function getUser(): ?User
(method_declaration
name: (name) @type-binding.name
return_type: (optional_type
(named_type
(name) @type-binding.type))) @type-binding.return
;; function_definition (top-level or namespace-level) return type: User
;; Enables cross-file return-type propagation for free functions.
(function_definition
name: (name) @type-binding.name
return_type: (named_type
(name) @type-binding.type)) @type-binding.return
;; nullable return type for function_definition: ?User
(function_definition
name: (name) @type-binding.name
return_type: (optional_type
(named_type
(name) @type-binding.type))) @type-binding.return
;; References free calls: foo()
(function_call_expression
function: (name) @reference.name) @reference.call.free
;; References member calls: $obj->method()
;;
;; SAFETY-INVARIANT (Finding 1 of PR #1497 adversarial review): the name:
;; field is constrained to (name), NOT (_) tree-sitter-php emits
;; variable_name nodes for dynamic method names ($obj->$method(),
;; $obj->{$method}()). Keeping the pattern at (name) is what suppresses
;; capture of those dynamic shapes. The resolver is structural-only and
;; cannot infer the bound method name from runtime values; relaxing this
;; pattern to (_) would silently emit zero-confidence false-positive
;; edges. Regression: test/fixtures/lang-resolution/php-dynamic-calls/.
(member_call_expression
object: (_) @reference.receiver
name: (name) @reference.name) @reference.call.member
;; References null-safe member calls: $obj?->method() (PHP 8+)
(nullsafe_member_call_expression
object: (_) @reference.receiver
name: (name) @reference.name) @reference.call.member
;; References static calls: X::method()
;;
;; Same SAFETY-INVARIANT as member_call_expression above: name: (name)
;; deliberately excludes variable_name so Class::$method() and
;; $className::$method() shapes do not capture. The receiver field uses
;; (_) because static dispatch on a variable receiver
;; ($className::method()) IS captured but resolution falls through
;; harmlessly when $className has no class type binding. See
;; php-dynamic-calls/ regression suite.
(scoped_call_expression
scope: (_) @reference.receiver
name: (name) @reference.name) @reference.call.member
;; Type bindings $x = X::Constant or $x = X::CASE (enum case)
;; Binds the variable to the class name X so member calls on $x dispatch
;; to X's methods (e.g. UserRole::Viewer label()).
;;
;; tree-sitter-php emits class_constant_access_expression with two name
;; children: [0]=class/enum name, [1]=constant/case name. The dot-anchor
;; before (name) matches only the FIRST name child (the class).
(assignment_expression
left: (variable_name) @type-binding.name
right: (class_constant_access_expression
. (name) @type-binding.type)) @type-binding.alias
(assignment_expression
left: (variable_name) @type-binding.name
right: (class_constant_access_expression
(qualified_name
(name) @type-binding.type))) @type-binding.alias
;; Type bindings $x = SomeClass::staticFactory()
;; Binds $x to the type returned by the static factory method, anchored on
;; the method name (chain-follow resolves the actual return type later).
(assignment_expression
left: (variable_name) @type-binding.name
right: (scoped_call_expression
name: (name) @type-binding.type)) @type-binding.alias
;; Type bindings null-safe member-call result: $x = $a?->getY()
(assignment_expression
left: (variable_name) @type-binding.name
right: (nullsafe_member_call_expression
name: (name) @type-binding.type)) @type-binding.alias
;; References constructor calls: new User()
(object_creation_expression
(name) @reference.name) @reference.call.constructor
(object_creation_expression
(qualified_name
(name) @reference.name)) @reference.call.constructor
;; References member writes: $obj->prop = $x
(assignment_expression
left: (member_access_expression
object: (_) @reference.receiver
name: (name) @reference.name)) @reference.write.member
;; References static property writes: User::$count = $x
;; Uses @reference.write.static anchor so captures.ts can strip the leading
;; $ from the variable_name capture (static props are stored without $ in graph).
;;
;; SAFETY-INVARIANT (Finding 2 of PR #1497 adversarial review): no
;; read-access property capture exists in this query dynamic property
;; reads ($obj->$prop, $obj->{$prop}) produce no captures, which is the
;; desired behavior for a structural-only resolver. Adding a read pattern
;; in the future MUST keep name: (name) (not (_)) to preserve the
;; suppression. Regression: php-dynamic-calls/ fixture dynamicPropertyRead.
(assignment_expression
left: (scoped_property_access_expression
scope: (_) @reference.receiver
name: (variable_name) @reference.name)) @reference.write.static
`;
let _parser: Parser | null = null;
let _query: Parser.Query | null = null;
export function getPhpParser(): Parser {
if (_parser === null) {
_parser = new Parser();
_parser.setLanguage(PHP_LANG as Parameters<Parser['setLanguage']>[0]);
}
return _parser;
}
export function getPhpScopeQuery(): Parser.Query {
if (_query === null) {
_query = new Parser.Query(PHP_LANG as Parameters<Parser['setLanguage']>[0], PHP_SCOPE_QUERY);
}
return _query;
}

View file

@ -0,0 +1,136 @@
/**
* Synthesize `@type-binding.self` captures for PHP instance methods
* one for `$this` (always on non-static methods inside a type
* declaration) and optionally one for `parent` (only on class methods
* when the enclosing class has an explicit `base_clause`).
*
* Mirrors `languages/csharp/receiver-binding.ts` in structure. PHP's
* grammar doesn't give us a clean `.scm` pattern for "implicit receiver
* on every instance method inside an enclosing type" because `$this` is
* not a parameter it's an implicit receiver. Synthesis in code is the
* same approach C# uses for `this` / `base`.
*
* ## Known limitations
*
* - **Trait `$this`**: for methods defined in a trait, `$this` is
* synthesized as a binding to the trait itself. The actual using-class
* type is not known at single-file parse time. V1 limitation
* documented in `index.ts`.
* - **Anonymous classes**: skipped (no stable enclosing class name).
*/
import type { Capture, CaptureMatch } from 'gitnexus-shared';
import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
const TYPE_DECL_NODE_TYPES = new Set([
'class_declaration',
'interface_declaration',
'trait_declaration',
'enum_declaration',
]);
const FUNCTION_NODE_TYPES = new Set([
'method_declaration',
'function_definition',
'anonymous_function',
'arrow_function',
]);
/** Walk up to find the enclosing type declaration. */
function findEnclosingTypeDeclaration(node: SyntaxNode): SyntaxNode | null {
let cur: SyntaxNode | null = node.parent;
while (cur !== null) {
if (TYPE_DECL_NODE_TYPES.has(cur.type)) return cur;
cur = cur.parent;
}
return null;
}
function typeName(typeNode: SyntaxNode): string | null {
return typeNode.childForFieldName('name')?.text ?? null;
}
/**
* Return the base class name from a `base_clause` child of the class node.
* `base_clause` contains a `qualified_name` or `name` child.
*/
function baseClauseText(typeNode: SyntaxNode): string | null {
for (let i = 0; i < typeNode.namedChildCount; i++) {
const child = typeNode.namedChild(i);
if (child === null || child.type !== 'base_clause') continue;
const nameNode = child.firstNamedChild;
if (nameNode === null) return null;
// Take last segment of qualified name (e.g. \App\Models\BaseModel → BaseModel)
const text = nameNode.text.trim();
const segments = text.split('\\').filter(Boolean);
return segments[segments.length - 1] ?? text;
}
return null;
}
/** Check whether this method has a `static_modifier` child. */
function isStaticMethod(fnNode: SyntaxNode): boolean {
for (let i = 0; i < fnNode.namedChildCount; i++) {
const child = fnNode.namedChild(i);
if (child !== null && child.type === 'static_modifier') return true;
}
return false;
}
/**
* Build zero, one, or two `@type-binding.self` matches for `fnNode`:
*
* - Returns `[]` if the function is free (no enclosing type), static,
* or the enclosing type has no resolvable name.
* - Returns one match (`$this`) for non-static methods inside a
* class / trait / interface / enum body.
* - Returns two matches (`$this` + `parent`) only when the function
* lives in a `class_declaration` that has an explicit `base_clause`.
*
* The caller is responsible for guaranteeing
* `FUNCTION_NODE_TYPES.has(fnNode.type)`.
*/
export function synthesizePhpReceiverBinding(fnNode: SyntaxNode): CaptureMatch[] {
if (!FUNCTION_NODE_TYPES.has(fnNode.type)) return [];
if (isStaticMethod(fnNode)) return [];
const enclosingType = findEnclosingTypeDeclaration(fnNode);
if (enclosingType === null) return [];
// Anonymous class — skip (no stable name).
if (enclosingType.type === 'anonymous_class_declaration') return [];
const enclosingName = typeName(enclosingType);
if (enclosingName === null) return [];
// Anchor the synthesized captures to the method body (compound_statement)
// so they land inside the function scope, not at the class scope.
// For interface/abstract methods that have no body, skip.
const bodyNode =
fnNode.childForFieldName('body') ??
// arrow_function: body is the expression after `=>`
fnNode.childForFieldName('return_value');
if (bodyNode === null) return [];
const out: CaptureMatch[] = [];
out.push(buildReceiverMatch(bodyNode, '$this', enclosingName));
// `parent` applies only to class methods with an explicit base_clause.
if (enclosingType.type === 'class_declaration') {
const baseText = baseClauseText(enclosingType);
if (baseText !== null) {
out.push(buildReceiverMatch(bodyNode, 'parent', baseText));
}
}
return out;
}
function buildReceiverMatch(anchorNode: SyntaxNode, name: string, typeText: string): CaptureMatch {
const m: Record<string, Capture> = {
'@type-binding.self': nodeToCapture('@type-binding.self', anchorNode),
'@type-binding.name': syntheticCapture('@type-binding.name', anchorNode, name),
'@type-binding.type': syntheticCapture('@type-binding.type', anchorNode, typeText),
};
return m;
}

View file

@ -0,0 +1,421 @@
/**
* PHP `ScopeResolver` registered in `SCOPE_RESOLVERS` and consumed by
* the generic `runScopeResolution` orchestrator (RFC #909 Ring 3 LANG-php).
*
* Third migration after Python and C#. See `pythonScopeResolver` for the
* canonical shape.
*
* ## Circular-import avoidance
*
* The old PR had `php/scope-resolver.ts` importing `phpProvider` from
* `../php.js` while `php.ts` imported `phpScopeResolver` from `./php/index.js`
* undefined at module load. The canonical fix (mirroring C#):
*
* - `scope-resolver.ts` imports `phpProvider` from `../php.js`
* - `php.ts` imports individual hook FUNCTIONS from `./php/index.js`
*
* Node's ESM handles the cycle correctly because `phpProvider` is a named
* export that is live-binding by the time `phpScopeResolver` is first
* read (lazily, at resolution time), `phpProvider` is fully initialized.
*/
import type { ParsedFile } from 'gitnexus-shared';
import { SupportedLanguages } from 'gitnexus-shared';
import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js';
import {
findReceiverTypeBinding,
populateClassOwnedMembers,
} from '../../scope-resolution/scope/walkers.js';
import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js';
import type { KnowledgeGraph } from '../../../graph/types.js';
import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-lookup.js';
import {
resolveCallerGraphId,
resolveDefGraphId,
} from '../../scope-resolution/graph-bridge/ids.js';
import { narrowOverloadCandidates } from '../../scope-resolution/passes/overload-narrowing.js';
import type { SemanticModel } from '../../model/semantic-model.js';
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
import type { SymbolDefinition } from 'gitnexus-shared';
import { phpProvider } from '../php.js';
import { phpArityCompatibility, phpMergeBindings } from './index.js';
import { resolvePhpImportTargetInternal, loadPhpComposerConfig } from './import-target.js';
import { populatePhpNamespaceSiblings, getPhpNamespaceForFile } from './namespace-siblings.js';
/**
* PHP MRO builder extends the generic EXTENDS-only MRO with trait-use
* relationships encoded as IMPLEMENTS edges.
*
* PHP trait-use (`use TraitName;` inside a class body) is recorded in the
* graph as an IMPLEMENTS edge from the using class to the Trait node. The
* generic `buildMro` only walks EXTENDS edges, so trait methods are invisible
* to the MRO-based dispatch index. This variant:
*
* 1. Runs the generic `buildMro` (EXTENDS edges, Class defs only).
* 2. Indexes Trait defs from `parsedFiles` alongside Class defs.
* 3. Walks IMPLEMENTS edges; for each edge whose target resolves to a
* Trait DefId, prepends that Trait DefId to the source class's MRO.
*
* Trait methods are searched BEFORE parent-class methods (PHP semantics:
* a trait method shadows the parent-class method but is overridden by the
* using class's own methods).
*/
/**
* PHP free-call visibility check for `pickUniqueGlobalCallable`. Returns
* true when the candidate function is reachable from the caller's PHP
* namespace context, false when the cross-namespace bridge would be a
* false positive (e.g., `\App\Utils\format` is not visible from `\App`
* without an explicit `use function App\Utils\format;`).
*
* Rules (PHP semantics):
* 1. Same-namespace candidates are always visible.
* 2. Global-namespace candidates (no namespace prefix) are visible from
* every caller PHP's global fallback for functions/constants.
* 3. Candidates in a different namespace are visible only when the
* caller has a `use function` import that matches the candidate's
* fully-qualified name.
*/
function phpIsCallableVisibleFromCaller(ctx: {
callerParsed: ParsedFile;
candidate: SymbolDefinition;
}): boolean {
const { callerParsed, candidate } = ctx;
const callerNs = getPhpNamespaceForFile(callerParsed.filePath);
const candNs = getPhpNamespaceForFile(candidate.filePath);
// Global-namespace candidate: PHP falls back to global for functions
// and constants when the local namespace doesn't define them.
if (candNs === '') return true;
// Same-namespace: caller can see the candidate without an explicit use.
if (candNs === callerNs) return true;
// Cross-namespace: require an explicit `use function` import in the
// caller's parsedImports that matches the candidate's fully-qualified
// name. interpret.ts maps `use function Foo\bar` to a named import with
// localName = 'bar' and targetRaw = 'Foo\\bar'.
const candQualified =
candidate.qualifiedName === undefined
? ''
: candNs !== '' && !candidate.qualifiedName.includes('\\')
? `${candNs}\\${candidate.qualifiedName}`
: candidate.qualifiedName;
if (candQualified === '') return false;
return callerParsed.parsedImports.some(
(imp) =>
imp.kind === 'named' &&
imp.targetRaw.replace(/^\\+/, '') === candQualified.replace(/^\\+/, ''),
);
}
/**
* Compute the EXTENDS-only ancestor chain for every class no trait
* augmentation. PHP semantics: `parent::method()` walks this view so
* that `parent::` resolves to the parent class's method, even when a
* composed trait shadows the same name.
*
* Returns the same shape as `buildPhpMro` so callers can swap views
* without changing dispatch logic. Just `buildMro` + `defaultLinearize`
* no trait IMPLEMENTS edge walk.
*/
function buildPhpExtendsOnlyMro(
graph: KnowledgeGraph,
parsedFiles: readonly ParsedFile[],
nodeLookup: GraphNodeLookup,
): Map<string, string[]> {
return buildMro(graph, parsedFiles, nodeLookup, defaultLinearize);
}
function buildPhpMro(
graph: KnowledgeGraph,
parsedFiles: readonly ParsedFile[],
nodeLookup: GraphNodeLookup,
): Map<string, string[]> {
// Step 1: run generic MRO (Class-only, EXTENDS-only).
const mro = buildMro(graph, parsedFiles, nodeLookup, defaultLinearize);
// Step 2: build a graphId → defId map for ALL class-like defs including Traits.
// After the `isLinkableLabel` fix, Trait nodes are now indexed in nodeLookup.
const defIdByGraphId = new Map<string, string>();
for (const parsed of parsedFiles) {
for (const def of parsed.localDefs) {
if (def.type !== 'Class' && def.type !== 'Trait') continue;
const graphId = resolveDefGraphId(parsed.filePath, def, nodeLookup);
if (graphId !== undefined) defIdByGraphId.set(graphId, def.nodeId);
}
}
// Step 2b: build a Set of Trait defIds for O(1) trait-vs-interface checks.
const traitDefIds = new Set<string>();
for (const parsed of parsedFiles) {
for (const def of parsed.localDefs) {
if (def.type === 'Trait') traitDefIds.add(def.nodeId);
}
}
// Step 3: collect direct trait-use edges (IMPLEMENTS where target is a Trait).
// Maps class/trait defId → [traitDefId, ...] for direct `use TraitName;`.
const directTraitUse = new Map<string, string[]>();
for (const rel of graph.iterRelationshipsByType('IMPLEMENTS')) {
const sourceDefId = defIdByGraphId.get(rel.sourceId);
if (sourceDefId === undefined) continue;
const targetDefId = defIdByGraphId.get(rel.targetId);
if (targetDefId === undefined) continue;
if (!traitDefIds.has(targetDefId)) continue;
let list = directTraitUse.get(sourceDefId);
if (list === undefined) {
list = [];
directTraitUse.set(sourceDefId, list);
}
if (!list.includes(targetDefId)) list.push(targetDefId);
}
// Step 4: augment every class's MRO by prepending the traits used by
// any class in its ancestor chain (transitively closed). PHP semantics:
// a trait used by a parent class is also visible on the child, and a
// trait-using-trait chain is flattened to a single ancestor set.
//
// For each class, walk its (already-computed) EXTENDS-based MRO and
// collect all transitively-used traits via BFS — `trait A { use B; }
// trait B { use C; } class X { use A; }` must include C in X's MRO.
// Prepend them before the EXTENDS ancestors so the method dispatch
// index finds trait methods before falling back to the parent class
// hierarchy.
for (const [classDefId, extendsMro] of mro) {
const ancestorChain = [classDefId, ...extendsMro];
const seeds: string[] = [];
for (const ancestorId of ancestorChain) {
for (const traitId of directTraitUse.get(ancestorId) ?? []) {
seeds.push(traitId);
}
}
const allTraits = collectTransitiveTraits(seeds, directTraitUse);
if (allTraits.length > 0) {
// Prepend traits before EXTENDS ancestors: own class's traits first,
// then parent traits (in ancestor order). This ensures trait methods
// are found before falling back to the inheritance chain.
mro.set(classDefId, [...allTraits, ...extendsMro]);
}
}
// Step 5: also insert Trait-only entries for classes that use traits
// directly but have no EXTENDS parents (not in `mro` yet).
for (const [classDefId, traits] of directTraitUse) {
if (!mro.has(classDefId) && !traitDefIds.has(classDefId)) {
// Class with no EXTENDS but with trait-use — add to MRO map.
const allTraits = collectTransitiveTraits([...traits], directTraitUse);
mro.set(classDefId, allTraits);
}
}
return mro;
}
/**
* Collect the transitive closure of traits reachable from the seed set.
* BFS over `directTraitUse` until fixpoint. The `seen` set guards against
* cycles (invalid PHP but defensively handled) and prevents duplicate
* entries when multiple seeds converge on the same trait. Insertion order
* is preserved first-seen wins for MRO ordering.
*/
function collectTransitiveTraits(
seeds: readonly string[],
directTraitUse: ReadonlyMap<string, readonly string[]>,
): string[] {
const out: string[] = [];
const seen = new Set<string>();
const queue: string[] = [...seeds];
while (queue.length > 0) {
const t = queue.shift()!;
if (seen.has(t)) continue;
seen.add(t);
out.push(t);
for (const next of directTraitUse.get(t) ?? []) {
if (!seen.has(next)) queue.push(next);
}
}
return out;
}
/**
* Emit CALLS edges for PHP member-call sites whose receiver has no type
* binding (e.g. `mixed`-typed parameters, untyped variables).
*
* PHP is dynamically typed: a parameter declared as `mixed` (or with no
* type hint) cannot be resolved by the generic receiver-bound pass, which
* requires a `TypeRef` in scope. This hook does a workspace-wide method
* name lookup: when exactly one def in the workspace matches the called
* method name, emit the CALLS edge.
*
* Only fires for sites that are NOT already in `handledSites` and whose
* receiver has no type binding in the scope chain. Unique-name-match
* constraint avoids false positives for common method names.
*/
function phpEmitUnresolvedReceiverEdges(
graph: KnowledgeGraph,
scopes: ScopeResolutionIndexes,
parsedFiles: readonly ParsedFile[],
nodeLookup: GraphNodeLookup,
handledSites: Set<string>,
model: SemanticModel,
): number {
let emitted = 0;
const seen = new Set<string>();
for (const parsed of parsedFiles) {
for (const site of parsed.referenceSites) {
if (site.kind !== 'call') continue;
if (site.explicitReceiver === undefined) continue;
const siteKey = `${parsed.filePath}:${site.atRange.startLine}:${site.atRange.startCol}`;
if (handledSites.has(siteKey)) continue;
// Only proceed when the receiver has NO type binding — it's unresolvable
// by the generic pass. This is the `mixed` / unannotated case.
const typeRef = findReceiverTypeBinding(site.inScope, site.explicitReceiver.name, scopes);
if (typeRef !== undefined) continue;
// Workspace-wide lookup: collect all methods matching the called name.
// Filter out defs with no qualifiedName (legacy parse stubs without full
// metadata) and deduplicate by nodeId so reconcileOwnership double-registration
// doesn't inflate the count.
const allCandidates = model.methods.lookupMethodByName(site.name);
const seen2 = new Set<string>();
const candidates = allCandidates.filter((c) => {
if (c.qualifiedName === undefined) return false;
if (seen2.has(c.nodeId)) return false;
seen2.add(c.nodeId);
return true;
});
if (candidates.length !== 1) continue; // ambiguous or missing — skip
const fnDef = candidates[0];
if (fnDef === undefined) continue;
// Apply arity narrowing — a unique method name match is not enough
// when arity says the call is definitively incompatible (e.g., PHP
// f(int $req, ...$rest) called with zero args). This prevents the
// fallback from emitting edges that the receiver-bound pass already
// rejected for arity reasons.
if (narrowOverloadCandidates([fnDef], site.arity, site.argumentTypes).length === 0) {
continue;
}
// Tighten the fallback further with an EXACT-required-arity gate
// (Finding 8 / U4): the first-stage `narrowOverloadCandidates`
// accepts any argCount in `min..max` (or `>= min` when variadic),
// which over-emits 0.6-confidence edges for common method names
// whose only workspace candidate has optional / defaulted params.
// For the fallback path only, require argCount === required for
// fixed-arity candidates. Variadic candidates keep the relaxed
// `argCount >= required` semantics (already enforced by the first-
// stage check, so no extra work here).
const min = fnDef.requiredParameterCount;
const hasVarArgs =
fnDef.parameterTypes !== undefined &&
fnDef.parameterTypes.some((t) => t === '...' || t.startsWith('...'));
if (
min !== undefined &&
Number.isFinite(site.arity) &&
site.arity >= 0 &&
!hasVarArgs &&
site.arity !== min
) {
continue;
}
const callerGraphId = resolveCallerGraphId(site.inScope, scopes, nodeLookup);
if (callerGraphId === undefined) continue;
const tgtGraphId = resolveDefGraphId(fnDef.filePath, fnDef, nodeLookup);
if (tgtGraphId === undefined) continue;
handledSites.add(siteKey);
const relId = `rel:CALLS:${callerGraphId}->${tgtGraphId}`;
if (seen.has(relId)) continue;
seen.add(relId);
graph.addRelationship({
id: relId,
sourceId: callerGraphId,
targetId: tgtGraphId,
type: 'CALLS',
confidence: 0.6,
reason: 'php-unresolved-receiver-fallback',
});
emitted++;
}
}
return emitted;
}
const phpScopeResolver: ScopeResolver = {
language: SupportedLanguages.PHP,
languageProvider: phpProvider,
importEdgeReason: 'php-scope: use',
resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig) =>
resolvePhpImportTargetInternal(targetRaw, fromFile, allFilePaths, resolutionConfig),
loadResolutionConfig: (repoPath) => loadPhpComposerConfig(repoPath),
// PHP LEGB-like precedence: local > import/namespace/reexport > wildcard.
// The per-scope id is unused by phpMergeBindings (tier ordering computed
// purely from BindingRef.origin), so we don't synthesize a Scope.
mergeBindings: (existing, incoming) => [...phpMergeBindings([...existing, ...incoming])],
// Adapter: phpArityCompatibility uses (def, callsite); the contract is (callsite, def).
arityCompatibility: (callsite, def) => phpArityCompatibility(def, callsite),
buildMro: (graph, parsedFiles, nodeLookup) => buildPhpMro(graph, parsedFiles, nodeLookup),
// PHP-specific: parent::method() must walk inheritance only, skipping
// composed traits. See buildPhpExtendsOnlyMro and the super-branch use
// in `passes/receiver-bound-calls.ts`.
buildExtendsOnlyMro: (graph, parsedFiles, nodeLookup) =>
buildPhpExtendsOnlyMro(graph, parsedFiles, nodeLookup),
// PHP free-call visibility: cross-namespace candidates are blocked
// unless explicitly `use function`-imported by the caller. Prevents
// false-positive CALLS edges between unrelated namespaces sharing a
// function name. Same-namespace and global-namespace candidates pass
// unchanged.
isCallableVisibleFromCaller: phpIsCallableVisibleFromCaller,
populateOwners: (parsed: ParsedFile) => populateClassOwnedMembers(parsed),
// PHP same-namespace cross-file visibility — classes in the same
// PHP namespace are visible without explicit `use` statements.
// Mirrors C#'s `populateNamespaceSiblings`.
populateNamespaceSiblings: populatePhpNamespaceSiblings,
// PHP uses `parent` for super-class dispatch (not `super()`).
isSuperReceiver: (text) => text.trim() === 'parent',
// PHP is dynamically typed — field-fallback heuristic on so that
// method calls on `mixed`-typed receivers (no annotation) fall back
// to a workspace-wide name search rather than silently dropping the edge.
fieldFallbackOnMethodLookup: true,
// PHP: allow free-call fallback to unique workspace-wide callable when
// lexical/import bindings miss. Needed for two cases:
// 1. `use function` imports where PSR-4 directory resolution is
// non-deterministic (multiple .php files in same namespace dir).
// 2. Unimported free calls within the same namespace (same-namespace
// visibility without an explicit use statement, e.g. test fixtures).
allowGlobalFreeCallFallback: true,
// Return-type propagation on — PHP method signatures are authoritative
// enough for cross-file chain-follow.
propagatesReturnTypesAcrossImports: true,
// PHP hoists method return-type bindings to the Module scope so
// `propagateImportedReturnTypes` can pick them up across files.
hoistTypeBindingsToModule: true,
// PHP recovers member calls on `mixed`/untyped receivers via a
// workspace-wide unique-method-name lookup, mirroring the legacy DAG.
emitUnresolvedReceiverEdges: phpEmitUnresolvedReceiverEdges,
};
export { phpScopeResolver };

View file

@ -0,0 +1,134 @@
/**
* Trivial / no-op-ish hooks for the PHP provider. Made explicit so
* reviewers don't have to re-derive the analysis from "absence == default".
*/
import type {
CaptureMatch,
ParsedImport,
Scope,
ScopeId,
ScopeTree,
TypeRef,
} from 'gitnexus-shared';
// ─── bindingScopeFor ──────────────────────────────────────────────────────
/**
* PHP method return-type bindings (`@type-binding.return`) must hoist
* to the enclosing Module scope so `propagateImportedReturnTypes` can
* mirror them across files. Without this hoist, the return binding gets
* stuck at the Class scope and is invisible to the cross-file propagation
* pass that reads only `sourceModule.typeBindings`.
*
* All other bindings delegate to the default "innermost scope" rule.
*/
export function phpBindingScopeFor(
decl: CaptureMatch,
innermost: Scope,
tree: ScopeTree,
): ScopeId | null {
if (decl['@type-binding.return'] !== undefined) {
let cur: Scope | undefined = innermost;
while (cur !== undefined && cur.kind !== 'Module') {
const parentId: ScopeId | null = cur.parent ?? null;
if (parentId === null) break;
cur = tree.getScope(parentId);
}
if (cur !== undefined && cur.kind === 'Module') return cur.id;
}
// Constructor-promoted properties (`function __construct(public User $u)`)
// are declared inside the constructor's Function scope in the AST, but they
// are class-owned fields. Hoist the @declaration.property binding to the
// enclosing Class scope so `populateClassOwnedMembers` assigns the correct
// ownerId and `findOwnedMember` can resolve `$obj->u`.
if (decl['@declaration.property'] !== undefined && innermost.kind === 'Function') {
let cur: Scope | undefined = innermost;
while (cur !== undefined && cur.kind !== 'Class') {
const parentId: ScopeId | null = cur.parent ?? null;
if (parentId === null) break;
cur = tree.getScope(parentId);
}
if (cur !== undefined && cur.kind === 'Class') return cur.id;
}
// Constructor-promoted property TYPE BINDING (`function __construct(public Address $address)`)
// produces both a @type-binding.parameter (stays in Function scope for `$address` lookups
// inside the constructor body) AND a @type-binding.annotation (query.ts). The annotation
// capture is emitted so this hoist branch can place `address → Address` in the CLASS scope.
//
// The compound-receiver resolver (`resolveCompoundReceiverClass`) reads typeBindings from
// the class scope: `cs.typeBindings.get('address')`. Without hoisting, `$user->address->save()`
// fails to resolve `address` because the type binding is in the constructor's Function scope.
//
// `@type-binding.annotation` for a promoted param appears with innermost = Function scope
// (the constructor). Regular typed class properties (`private Address $addr;`) have their
// annotation already in the Class scope, so this branch only fires for promoted params.
if (decl['@type-binding.annotation'] !== undefined && innermost.kind === 'Function') {
let cur: Scope | undefined = innermost;
while (cur !== undefined && cur.kind !== 'Class') {
const parentId: ScopeId | null = cur.parent ?? null;
if (parentId === null) break;
cur = tree.getScope(parentId);
}
if (cur !== undefined && cur.kind === 'Class') return cur.id;
}
return null;
}
// ─── importOwningScope ────────────────────────────────────────────────────
/**
* Determine which scope owns a `use` import declaration.
*
* - `use` inside `namespace Foo { }` attach to that Namespace scope.
* - Top-level `use` (no enclosing namespace) innermost (Module).
* - `use TraitName;` inside a class body this is a trait-use
* (heritage), NOT a namespace import. The grammar emits
* `use_declaration` for trait-use (distinct from
* `namespace_use_declaration`). Our query only captures
* `namespace_use_declaration`, so trait-use never reaches this hook
* in practice. Returning `null` here is a safety fallback.
*/
export function phpImportOwningScope(
_imp: ParsedImport,
innermost: Scope,
_tree: ScopeTree,
): ScopeId | null {
// Namespace-scoped or module-scoped imports attach to the innermost scope
// (either Namespace or Module). Class-scoped imports should not occur for
// namespace_use_declaration; if they do, attach to the class scope.
if (
innermost.kind === 'Namespace' ||
innermost.kind === 'Module' ||
innermost.kind === 'Class' ||
innermost.kind === 'Function'
) {
return innermost.id;
}
return null;
}
// ─── receiverBinding ──────────────────────────────────────────────────────
/**
* Look up `$this` or `parent` in the function scope's type bindings.
*
* Both are synthesized as `@type-binding.self` captures during capture
* emission (`receiver-binding.ts`) `$this` for every non-static
* method inside a class/trait/interface/enum body, `parent` additionally
* for class methods with an explicit `base_clause`.
*
* Returns `null` for:
* - static methods (no `$this` synthesized)
* - free functions (no enclosing class)
* - non-Function scopes
*/
export function phpReceiverBinding(functionScope: Scope): TypeRef | null {
if (functionScope.kind !== 'Function') return null;
return (
functionScope.typeBindings.get('$this') ?? functionScope.typeBindings.get('parent') ?? null
);
}

View file

@ -24,6 +24,7 @@ import { synthesizeReceiverTypeBinding } from './receiver-binding.js';
import { computePythonArityMetadata } from './arity-metadata.js';
import { recordCacheHit, recordCacheMiss } from './cache-stats.js';
import { getTreeSitterBufferSize } from '../../constants.js';
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
import { pythonFunctionDefinitionLabel } from './simple-hooks.js';
export function emitPythonScopeCaptures(
@ -39,7 +40,7 @@ export function emitPythonScopeCaptures(
let tree = cachedTree as ReturnType<ReturnType<typeof getPythonParser>['parse']> | undefined;
if (tree === undefined) {
try {
tree = getPythonParser().parse(sourceText, undefined, {
tree = parseSourceSafe(getPythonParser(), sourceText, undefined, {
bufferSize: getTreeSitterBufferSize(sourceText),
});
} catch (err) {

View file

@ -38,6 +38,7 @@ import { recordCacheHit, recordCacheMiss } from './cache-stats.js';
import { synthesizeTsReceiverBinding } from './receiver-binding.js';
import { computeTsArityMetadata } from './arity-metadata.js';
import { getTreeSitterBufferSize } from '../../constants.js';
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
/** tree-sitter-typescript node types for function-like scopes that may
* carry a synthesized `this` binding. Kept in sync with the
@ -134,7 +135,7 @@ export function emitTsScopeCaptures(
tree = undefined;
}
if (tree === undefined) {
tree = getTsParser(filePath).parse(sourceText, undefined, {
tree = parseSourceSafe(getTsParser(filePath), sourceText, undefined, {
bufferSize: getTreeSitterBufferSize(sourceText),
});
recordCacheMiss();

View file

@ -5,12 +5,11 @@ import { loadParser, loadLanguage, isLanguageAvailable } from '../tree-sitter/pa
import { getProvider } from './languages/index.js';
import { generateId } from '../../lib/utils.js';
import type { SymbolTableReader, SymbolTableWriter, ExtractedHeritage } from './model/index.js';
// SymbolTableReader is used for the FieldExtractorContext stub; the
// parsing functions themselves need Writer because they call .add().
import { ASTCache } from './ast-cache.js';
import { getLanguageFromFilename, SupportedLanguages } from 'gitnexus-shared';
import { extractVueScript, isVueSetupTopLevel } from './vue-sfc-extractor.js';
import { yieldToEventLoop } from './utils/event-loop.js';
import { parseSourceSafe } from '../tree-sitter/safe-parse.js';
import { isVerboseIngestionEnabled } from './utils/verbose.js';
import {
getDefinitionNodeFromCaptures,
@ -83,6 +82,88 @@ export interface WorkerExtractedData {
// Worker-based parallel parsing
// ============================================================================
/**
* Merge a list of `ParseWorkerResult`s into the running graph + symbol
* table state and produce the chunk-aggregated `WorkerExtractedData`.
*
* Extracted from `processParsingWithWorkers` so the same merge logic can
* be applied to both freshly-parsed worker output AND cached worker
* output replayed during incremental analyze. Idempotent on the
* accumulator fields (push-only); idempotent on graph if the caller
* starts from a clean graph (otherwise duplicate `addNode` calls are
* silently no-op'd by `KnowledgeGraph`).
*/
export const mergeChunkResults = (
graph: KnowledgeGraph,
symbolTable: SymbolTableWriter,
chunkResults: readonly ParseWorkerResult[],
): WorkerExtractedData => {
const allImports: ExtractedImport[] = [];
const allCalls: ExtractedCall[] = [];
const allAssignments: ExtractedAssignment[] = [];
const allHeritage: ExtractedHeritage[] = [];
const allRoutes: ExtractedRoute[] = [];
const allFetchCalls: ExtractedFetchCall[] = [];
const allDecoratorRoutes: ExtractedDecoratorRoute[] = [];
const allToolDefs: ExtractedToolDef[] = [];
const allORMQueries: ExtractedORMQuery[] = [];
const allConstructorBindings: FileConstructorBindings[] = [];
const fileScopeBindingsByFile: FileScopeBindings[] = [];
const allParsedFiles: ParsedFile[] = [];
for (const result of chunkResults) {
for (const node of result.nodes) {
graph.addNode({
id: node.id,
label: node.label as NodeLabel,
properties: node.properties,
});
}
for (const rel of result.relationships) {
graph.addRelationship(rel);
}
for (const sym of result.symbols) {
symbolTable.add(sym.filePath, sym.name, sym.nodeId, sym.type, {
parameterCount: sym.parameterCount,
requiredParameterCount: sym.requiredParameterCount,
parameterTypes: sym.parameterTypes,
returnType: sym.returnType,
declaredType: sym.declaredType,
ownerId: sym.ownerId,
qualifiedName: sym.qualifiedName,
});
}
for (const item of result.imports) allImports.push(item);
for (const item of result.calls) allCalls.push(item);
for (const item of result.assignments) allAssignments.push(item);
for (const item of result.heritage) allHeritage.push(item);
for (const item of result.routes) allRoutes.push(item);
for (const item of result.fetchCalls) allFetchCalls.push(item);
for (const item of result.decoratorRoutes) allDecoratorRoutes.push(item);
for (const item of result.toolDefs) allToolDefs.push(item);
if (result.ormQueries) for (const item of result.ormQueries) allORMQueries.push(item);
for (const item of result.constructorBindings) allConstructorBindings.push(item);
if (result.fileScopeBindings)
for (const item of result.fileScopeBindings) fileScopeBindingsByFile.push(item);
if (result.parsedFiles) for (const item of result.parsedFiles) allParsedFiles.push(item);
}
return {
imports: allImports,
calls: allCalls,
assignments: allAssignments,
heritage: allHeritage,
routes: allRoutes,
fetchCalls: allFetchCalls,
decoratorRoutes: allDecoratorRoutes,
toolDefs: allToolDefs,
ormQueries: allORMQueries,
constructorBindings: allConstructorBindings,
fileScopeBindings: fileScopeBindingsByFile,
parsedFiles: allParsedFiles,
};
};
const processParsingWithWorkers = async (
graph: KnowledgeGraph,
files: { path: string; content: string }[],
@ -90,6 +171,14 @@ const processParsingWithWorkers = async (
astCache: ASTCache,
workerPool: WorkerPool,
onFileProgress?: FileProgressCallback,
/**
* When provided, populated with the raw worker results before merging.
* Used by the incremental-indexing parse cache to capture the per-chunk
* worker output for caching across runs. The mutation happens in-place
* so the caller (parse-impl) can keep a reference. See
* `gitnexus/src/storage/parse-cache.ts`.
*/
outRawResults?: ParseWorkerResult[],
): Promise<WorkerExtractedData> => {
// Filter to parseable files only
const parseableFiles: ParseWorkerInput[] = [];
@ -124,63 +213,16 @@ const processParsingWithWorkers = async (
},
);
// Merge results from all workers into graph and symbol table
const allImports: ExtractedImport[] = [];
const allCalls: ExtractedCall[] = [];
const allAssignments: ExtractedAssignment[] = [];
const allHeritage: ExtractedHeritage[] = [];
const allRoutes: ExtractedRoute[] = [];
const allFetchCalls: ExtractedFetchCall[] = [];
const allDecoratorRoutes: ExtractedDecoratorRoute[] = [];
const allToolDefs: ExtractedToolDef[] = [];
const allORMQueries: ExtractedORMQuery[] = [];
const allConstructorBindings: FileConstructorBindings[] = [];
const fileScopeBindingsByFile: FileScopeBindings[] = [];
const allParsedFiles: ParsedFile[] = [];
for (const result of chunkResults) {
for (const node of result.nodes) {
graph.addNode({
id: node.id,
label: node.label as NodeLabel,
properties: node.properties,
});
}
for (const rel of result.relationships) {
graph.addRelationship(rel);
}
for (const sym of result.symbols) {
symbolTable.add(sym.filePath, sym.name, sym.nodeId, sym.type, {
parameterCount: sym.parameterCount,
requiredParameterCount: sym.requiredParameterCount,
parameterTypes: sym.parameterTypes,
returnType: sym.returnType,
declaredType: sym.declaredType,
ownerId: sym.ownerId,
qualifiedName: sym.qualifiedName,
});
}
for (const item of result.imports) allImports.push(item);
for (const item of result.calls) allCalls.push(item);
for (const item of result.assignments) allAssignments.push(item);
for (const item of result.heritage) allHeritage.push(item);
for (const item of result.routes) allRoutes.push(item);
for (const item of result.fetchCalls) allFetchCalls.push(item);
for (const item of result.decoratorRoutes) allDecoratorRoutes.push(item);
for (const item of result.toolDefs) allToolDefs.push(item);
if (result.ormQueries) for (const item of result.ormQueries) allORMQueries.push(item);
for (const item of result.constructorBindings) allConstructorBindings.push(item);
if (result.fileScopeBindings)
for (const item of result.fileScopeBindings) fileScopeBindingsByFile.push(item);
// RFC #909 Ring 2: aggregate per-file scope artifacts. Tolerant of
// workers that don't emit the field yet (older worker builds or
// partial rollouts), since the additive contract means undefined =
// "this worker produced no ParsedFiles for this chunk".
if (result.parsedFiles) for (const item of result.parsedFiles) allParsedFiles.push(item);
// Capture the raw chunk results for the incremental parse cache before
// merging — the cache stores the unmerged worker output so a future run
// can re-merge them into a fresh graph state.
if (outRawResults) {
for (const r of chunkResults) outRawResults.push(r);
}
// Merge results from all workers into graph and symbol table.
const merged = mergeChunkResults(graph, symbolTable, chunkResults);
// Merge and log skipped languages from workers
const skippedLanguages = new Map<string, number>();
for (const result of chunkResults) {
@ -197,20 +239,7 @@ const processParsingWithWorkers = async (
// Final progress
onFileProgress?.(total, total, 'done');
return {
imports: allImports,
calls: allCalls,
assignments: allAssignments,
heritage: allHeritage,
routes: allRoutes,
fetchCalls: allFetchCalls,
decoratorRoutes: allDecoratorRoutes,
toolDefs: allToolDefs,
ormQueries: allORMQueries,
constructorBindings: allConstructorBindings,
fileScopeBindings: fileScopeBindingsByFile,
parsedFiles: allParsedFiles,
};
return merged;
};
// ============================================================================
@ -384,7 +413,7 @@ const processParsingSequential = async (
let tree: Parser.Tree;
try {
tree = parser.parse(parseContent, undefined, {
tree = parseSourceSafe(parser, parseContent, undefined, {
bufferSize: getTreeSitterBufferSize(parseContent),
});
} catch (parseError) {
@ -733,6 +762,14 @@ export const processParsing = async (
scopeTreeCache: ASTCache | undefined,
onFileProgress?: FileProgressCallback,
workerPool?: WorkerPool,
/**
* Optional out-parameter for the incremental parse cache. When
* provided AND the worker-pool path runs successfully, populated
* with the raw `ParseWorkerResult[]` from the workers (pre-merge).
* Stays empty for the sequential fallback path (no per-chunk
* artifact to cache there). See `gitnexus/src/storage/parse-cache.ts`.
*/
outRawResults?: ParseWorkerResult[],
): Promise<WorkerExtractedData | null> => {
let lastProgress = 0;
const reportProgress: FileProgressCallback | undefined = onFileProgress
@ -760,6 +797,7 @@ export const processParsing = async (
astCache,
workerPool,
reportProgress,
outRawResults,
);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);

View file

@ -17,7 +17,10 @@ import {
enrichExportedTypeMap,
type BindingEntry,
} from '../binding-accumulator.js';
import { processParsing } from '../parsing-processor.js';
import { processParsing, mergeChunkResults } from '../parsing-processor.js';
import { fileContentHash, computeChunkHash } from '../../../storage/parse-cache.js';
import type { ParseWorkerResult } from '../workers/parse-worker.js';
import type { WorkerExtractedData } from '../parsing-processor.js';
import {
processImports,
processImportsFromExtracted,
@ -72,8 +75,21 @@ import { extractORMQueriesInline } from './orm-extraction.js';
import { logger } from '../../logger.js';
// ── Constants ──────────────────────────────────────────────────────────────
/** Max bytes of source content to load per parse chunk. */
const CHUNK_BYTE_BUDGET = 20 * 1024 * 1024; // 20MB
/** Max bytes of source content to load per parse chunk.
*
* Memory bound for the worker pool dispatch + a granularity knob for
* the parse cache. A single file change invalidates only its enclosing
* chunk, so smaller budgets finer-grained invalidation.
*
* Override via GITNEXUS_CHUNK_BYTE_BUDGET (bytes) the default of 2MB
* gives a useful invalidation floor (~1/N chunks on a multi-MB repo)
* while keeping worker dispatch overhead under 5% on cold runs.
*/
const CHUNK_BYTE_BUDGET = (() => {
const env = Number(process.env.GITNEXUS_CHUNK_BYTE_BUDGET);
if (Number.isFinite(env) && env > 0) return env;
return 2 * 1024 * 1024;
})();
// ── Main parse + resolve function ──────────────────────────────────────────
@ -119,6 +135,11 @@ export async function runChunkedParseAndResolve(
* source. See plan
* docs/plans/2026-04-20-002-perf-parse-heritage-mro-plan.md (Unit 4). */
scopeTreeCache: ASTCache;
/** Worker-produced ParsedFile artifacts aggregated across chunks.
* Threaded into scope-resolution as a re-extract cache so the warm-
* cache analyze run can skip the dominant `extractParsedFile` cost
* (otherwise ~58s on a 1000-file repo). */
parsedFiles: import('gitnexus-shared').ParsedFile[];
}> {
const ctx = createResolutionContext();
const symbolTable = ctx.model.symbols;
@ -142,6 +163,15 @@ export async function runChunkedParseAndResolve(
);
}
// Sort parseableScanned alphabetically for stable chunk membership
// across runs (Finding 4). Without this, filesystem-scan order can
// shift between runs (notably on macOS APFS where directory entry
// order can change after modifications) — different files in the
// same chunk → different chunk hash → cache miss even when no file
// content changed. The cache also becomes platform-specific: a
// Linux-built cache misses on macOS for the same repo.
parseableScanned.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
const totalParseable = parseableScanned.length;
if (totalParseable === 0) {
@ -271,6 +301,20 @@ export async function runChunkedParseAndResolve(
const deferredWorkerHeritage: ExtractedHeritage[] = [];
const deferredConstructorBindings: FileConstructorBindings[] = [];
const deferredAssignments: ExtractedAssignment[] = [];
// Aggregated per-file ParsedFile artifacts produced by workers' calls
// to `extractParsedFile`. Threaded through to the scope-resolution
// phase so it can SKIP its own re-extraction on cache hits — this is
// the second-half of the parse-cache speedup since scope-resolution's
// re-parse otherwise dominates the warm-cache wall-clock time.
const allParsedFiles: import('gitnexus-shared').ParsedFile[] = [];
// Incremental parse cache (Option B): chunk-level content-addressed.
// When the chunk's (filePath, content-hash) signature matches a prior
// run's, replay the cached ParseWorkerResult[] instead of dispatching
// to workers. See gitnexus/src/storage/parse-cache.ts.
const parseCache = options?.parseCache;
let chunkCacheHits = 0;
let chunkCacheMisses = 0;
try {
for (let chunkIdx = 0; chunkIdx < numChunks; chunkIdx++) {
@ -281,29 +325,89 @@ export async function runChunkedParseAndResolve(
.filter((p) => chunkContents.has(p))
.map((p) => ({ path: p, content: chunkContents.get(p)! }));
const chunkWorkerData = await processParsing(
graph,
chunkFiles,
symbolTable,
astCache,
scopeTreeCache,
(current, _total, filePath) => {
const globalCurrent = filesParsedSoFar + current;
const parsingProgress = 20 + (globalCurrent / totalParseable) * 62;
onProgress({
phase: 'parsing',
percent: Math.round(parsingProgress),
message: `Parsing chunk ${chunkIdx + 1}/${numChunks}...`,
detail: filePath,
stats: {
filesProcessed: globalCurrent,
totalFiles: totalParseable,
nodesCreated: graph.nodeCount,
},
});
},
workerPool,
);
// Compute the chunk's content-hash signature (if cache available).
let chunkHash: string | null = null;
if (parseCache) {
const entries = chunkFiles.map((f) => ({
filePath: f.path,
contentHash: fileContentHash(f.content),
}));
chunkHash = computeChunkHash(entries);
}
let chunkWorkerData: WorkerExtractedData | null;
const cachedRaw = chunkHash ? parseCache!.entries.get(chunkHash) : undefined;
// Track every chunk hash we touched so the orchestrator can
// prune stale entries (chunks whose composition no longer
// corresponds to a live chunk in the current scan) before saving.
if (parseCache && chunkHash) parseCache.usedKeys.add(chunkHash);
if (cachedRaw && cachedRaw.length > 0) {
// Cache hit: replay the cached worker output through the same
// merge logic the live worker path uses.
chunkCacheHits++;
chunkWorkerData = mergeChunkResults(graph, symbolTable, cachedRaw);
if (isDev) {
logger.info(
`📦 parse-cache HIT: chunk ${chunkIdx + 1}/${numChunks} (${chunkFiles.length} files, ${chunkHash!.slice(0, 8)})`,
);
}
// Progress update so UI advances even on a cache hit.
const cachedFiles = chunkFiles.length;
onProgress({
phase: 'parsing',
percent: Math.round(20 + ((filesParsedSoFar + cachedFiles) / totalParseable) * 62),
message: `Parsing chunk ${chunkIdx + 1}/${numChunks} (cache)...`,
stats: {
filesProcessed: filesParsedSoFar + cachedFiles,
totalFiles: totalParseable,
nodesCreated: graph.nodeCount,
},
});
} else {
// Cache miss: dispatch to workers, capture the raw results, store
// them under the chunk hash for the next run.
chunkCacheMisses++;
const rawResults: ParseWorkerResult[] = [];
chunkWorkerData = await processParsing(
graph,
chunkFiles,
symbolTable,
astCache,
scopeTreeCache,
(current, _total, filePath) => {
const globalCurrent = filesParsedSoFar + current;
const parsingProgress = 20 + (globalCurrent / totalParseable) * 62;
onProgress({
phase: 'parsing',
percent: Math.round(parsingProgress),
message: `Parsing chunk ${chunkIdx + 1}/${numChunks}...`,
detail: filePath,
stats: {
filesProcessed: globalCurrent,
totalFiles: totalParseable,
nodesCreated: graph.nodeCount,
},
});
},
workerPool,
// Capture raw results only when we have a cache to write to —
// otherwise we'd retain extra arrays for nothing.
parseCache && chunkHash ? rawResults : undefined,
);
// Persist the raw results for this chunk hash. Sequential path
// doesn't populate rawResults (it writes directly to graph), so
// small repos without worker pool simply don't cache. That's fine.
if (parseCache && chunkHash && rawResults.length > 0) {
parseCache.entries.set(chunkHash, rawResults);
if (isDev) {
logger.info(
`📦 parse-cache MISS+store: chunk ${chunkIdx + 1}/${numChunks} (${chunkFiles.length} files, ${chunkHash.slice(0, 8)})`,
);
}
}
}
const chunkBasePercent = 20 + (filesParsedSoFar / totalParseable) * 62;
@ -349,6 +453,12 @@ export async function runChunkedParseAndResolve(
for (const item of chunkWorkerData.heritage) deferredWorkerHeritage.push(item);
for (const item of chunkWorkerData.constructorBindings)
deferredConstructorBindings.push(item);
// Aggregate worker-produced ParsedFile artifacts so scope-
// resolution can use them as a re-extraction cache (skips its
// own tree-sitter re-parse on warm runs).
if (chunkWorkerData.parsedFiles?.length) {
for (const item of chunkWorkerData.parsedFiles) allParsedFiles.push(item);
}
if (chunkWorkerData.assignments?.length) {
for (const item of chunkWorkerData.assignments) deferredAssignments.push(item);
}
@ -422,6 +532,12 @@ export async function runChunkedParseAndResolve(
astCache.clear();
}
if (isDev && parseCache && (chunkCacheHits > 0 || chunkCacheMisses > 0)) {
logger.info(
`📦 parse-cache summary: ${chunkCacheHits} chunk hit(s), ${chunkCacheMisses} miss(es) across ${numChunks} chunk(s)`,
);
}
const fullWorkerHeritageMap =
deferredWorkerHeritage.length > 0
? buildHeritageMap(deferredWorkerHeritage, ctx, getHeritageStrategyForLanguage)
@ -621,5 +737,12 @@ export async function runChunkedParseAndResolve(
// chunk-local `astCache` above is intentionally NOT exposed
// because parse-impl clears it between chunks.
scopeTreeCache,
// Per-file ParsedFile artifacts produced by workers' calls to
// `extractParsedFile`. Empty when only the sequential path ran
// (sequential doesn't go through the worker, and extracts ParsedFile
// inline rather than emitting it). Consumed by scope-resolution as
// a re-extraction cache: when the file's ParsedFile is here,
// scope-resolution skips its own `extractParsedFile` call.
parsedFiles: allParsedFiles,
};
}

View file

@ -20,6 +20,7 @@ import type { PipelinePhase, PipelineContext, PhaseResult } from './types.js';
import { getPhaseOutput } from './types.js';
import type { StructureOutput } from './structure.js';
import type { BindingAccumulator } from '../binding-accumulator.js';
import type { ParsedFile } from 'gitnexus-shared';
import type {
ExtractedFetchCall,
ExtractedRoute,
@ -81,6 +82,19 @@ export interface ParseOutput {
* `scopeTreeCache.clear()` after its extract loop finishes.
*/
readonly scopeTreeCache: ASTCache;
/**
* Per-file `ParsedFile` artifacts produced by workers' calls to
* `extractParsedFile`. Threaded through to `scopeResolutionPhase`
* as a re-extraction cache: when a file's ParsedFile is present here,
* scope-resolution can skip its own `extractParsedFile` (which would
* otherwise re-parse the file with tree-sitter on the main thread,
* costing ~58s on a 1000-file repo).
*
* Empty for files that went through the sequential parse fallback
* sequential doesn't emit ParsedFile artifacts; scope-resolution
* falls back to a fresh extract for those.
*/
readonly parsedFiles: readonly ParsedFile[];
}
export const parsePhase: PipelinePhase<ParseOutput> = {

View file

@ -55,6 +55,19 @@ export interface PipelineOptions {
minFiles?: number;
minBytes?: number;
};
/**
* Incremental-indexing parse cache. When provided:
* - The parse phase looks up each chunk's content hash in
* `parseCache.entries`. On hit, it replays the cached
* `ParseWorkerResult[]` instead of dispatching to workers.
* - On miss, it runs the workers as today and stores the new
* results in `parseCache.entries` keyed by chunk hash.
* The caller (`run-analyze.ts`) is responsible for loading the cache
* before the pipeline runs and persisting it after. Cache survives
* `--force` because keys are content-addressed.
* See `gitnexus/src/storage/parse-cache.ts`.
*/
parseCache?: import('../../storage/parse-cache.js').ParseCache;
}
// ── Phase registry ─────────────────────────────────────────────────────────

View file

@ -71,6 +71,8 @@ export const MIGRATED_LANGUAGES: ReadonlySet<SupportedLanguages> = new Set<Suppo
SupportedLanguages.CSharp,
SupportedLanguages.TypeScript,
SupportedLanguages.Go,
SupportedLanguages.C,
SupportedLanguages.PHP,
]);
/**

View file

@ -386,6 +386,26 @@ export interface ScopeResolver {
nodeLookup: GraphNodeLookup,
): Map<string /* DefId */, string[] /* ancestor DefIds */>;
/**
* Optional parallel MRO that EXCLUDES mixin-like augmentation (e.g., PHP
* traits). Returns the inheritance-only ancestor chain the same kind
* of map as `buildMro` but built only from inheritance edges (EXTENDS).
*
* Used by the shared super-branch dispatch in `receiver-bound-calls`
* so that `parent::method()` walks the inheritance chain only, not the
* trait-augmented one. PHP semantics: `parent::` explicitly bypasses
* traits, even when a composed trait shadows a same-named parent method.
*
* Languages without mixin-like semantics leave this undefined callers
* fall back to `buildMro`/`mroFor`, which for those languages is already
* the inheritance chain.
*/
readonly buildExtendsOnlyMro?: (
graph: KnowledgeGraph,
parsedFiles: readonly ParsedFile[],
nodeLookup: GraphNodeLookup,
) => Map<string /* DefId */, string[] /* ancestor DefIds */>;
/**
* Mutate `parsed.localDefs[i].ownerId` to point at the structural
* owner. Python's rule: methods (Function defs whose parent scope
@ -472,6 +492,38 @@ export interface ScopeResolver {
*/
readonly allowGlobalFreeCallFallback?: boolean;
/**
* Optional predicate to identify definitions with file-local linkage
* (e.g. C `static` functions). When provided, `pickUniqueGlobalCallable`
* excludes defs where `isFileLocalDef(def) === true` and the def lives
* in a different file from the caller. This prevents the global free-call
* fallback from creating CALLS edges to file-local symbols that are
* logically invisible from the caller's translation unit.
*
* Languages without file-local linkage semantics leave this undefined.
*/
readonly isFileLocalDef?: (def: SymbolDefinition) => boolean;
/**
* Optional predicate to gate free-call fallback emission by caller-side
* visibility. When provided, `pickUniqueGlobalCallable` rejects candidates
* the caller cannot legally reach e.g., a PHP function in a different
* namespace with no `use function` import, which PHP runtime would treat
* as `Call to undefined function`. Returning `false` blocks the candidate;
* returning `true` allows it; undefined-default keeps current behavior
* (no visibility filtering, equivalent to "all candidates visible").
*
* The hook receives the caller's `ParsedFile` (so it can consult
* `parsedImports`, `moduleScope`, etc.) and the candidate `SymbolDefinition`.
* The predicate must be pure: same inputs same answer.
*
* Languages without namespace-scoped function resolution leave this undefined.
*/
readonly isCallableVisibleFromCaller?: (ctx: {
readonly callerParsed: ParsedFile;
readonly candidate: SymbolDefinition;
}) => boolean;
/**
* Optional post-finalize hook to inject cross-file bindings that
* aren't modeled via explicit imports. Runs after
@ -564,4 +616,32 @@ export interface ScopeResolver {
readonly treeCache?: { get(filePath: string): unknown };
},
) => void;
/**
* Optional post-resolution pass: emit CALLS edges for member-call sites
* whose receiver cannot be typed by the scope chain (no `TypeRef`).
* Dynamically-typed languages with untyped/`mixed`/`Any` parameters use
* this hook to recover the call edge via workspace-wide method-name
* lookup, mirroring what their legacy resolvers did.
*
* Runs AFTER `emitReceiverBoundCalls` and BEFORE `emitFreeCallFallback`.
* Implementations MUST:
* - Skip sites already in `handledSites` (Invariant I2).
* - Add resolved site keys to `handledSites` before returning.
* - Stay narrow: a unique workspace-wide match is the safe baseline.
* Multi-candidate fallbacks should narrow by arity / argument types
* before emitting to keep false-positive rate bounded.
*
* Returns the number of edges emitted (for telemetry).
*
* Default: undefined (no unresolved-receiver fallback).
*/
readonly emitUnresolvedReceiverEdges?: (
graph: KnowledgeGraph,
scopes: ScopeResolutionIndexes,
parsedFiles: readonly ParsedFile[],
nodeLookup: GraphNodeLookup,
handledSites: Set<string>,
model: SemanticModel,
) => number;
}

View file

@ -22,8 +22,9 @@ const EMPTY_DEFS: readonly string[] = Object.freeze([]);
export function buildPopulatedMethodDispatch(
mroByDefId: ReadonlyMap<string, readonly string[]>,
extendsOnlyMroByDefId?: ReadonlyMap<string, readonly string[]>,
): MethodDispatchIndex {
return {
const base: MethodDispatchIndex = {
mroByOwnerDefId: mroByDefId,
implsByInterfaceDefId: new Map(),
mroFor(ownerDefId) {
@ -33,4 +34,14 @@ export function buildPopulatedMethodDispatch(
return EMPTY_DEFS;
},
};
if (extendsOnlyMroByDefId !== undefined) {
return {
...base,
extendsOnlyMroByOwnerDefId: extendsOnlyMroByDefId,
extendsOnlyMroFor(ownerDefId) {
return extendsOnlyMroByDefId.get(ownerDefId) ?? EMPTY_DEFS;
},
};
}
return base;
}

View file

@ -117,6 +117,11 @@ export function isLinkableLabel(label: NodeLabel): boolean {
label === 'Interface' ||
label === 'Struct' ||
label === 'Enum' ||
// Trait nodes are linkable so MRO builders can bridge PHP/Rust trait
// defs between scope-resolution DefIds and the graph's node ids.
// IMPLEMENTS edges from classes to traits are otherwise invisible to
// the scope-resolution MRO pass.
label === 'Trait' ||
// Variable / Property are linkable too — receiver-bound write/read
// ACCESSES edges target field nodes (e.g. `user.name = "x"` →
// ACCESSES edge to User's `name` Variable/Property node).

View file

@ -36,7 +36,14 @@ export function emitFreeCallFallback(
handledSites: Set<string>,
model: SemanticModel,
workspaceIndex: WorkspaceResolutionIndex,
options: { readonly allowGlobalFallback?: boolean } = {},
options: {
readonly allowGlobalFallback?: boolean;
readonly isFileLocalDef?: (def: SymbolDefinition) => boolean;
readonly isCallableVisibleFromCaller?: (ctx: {
readonly callerParsed: ParsedFile;
readonly candidate: SymbolDefinition;
}) => boolean;
} = {},
): number {
let emitted = 0;
const seen = new Set<string>();
@ -73,7 +80,18 @@ export function emitFreeCallFallback(
// the caller does not import the target package. Same-package calls are
// caught by findCallableBindingInScope above before reaching here.
if (fnDef === undefined && options.allowGlobalFallback === true) {
fnDef = pickUniqueGlobalCallable(site.name, model, scopes);
fnDef = pickUniqueGlobalCallable(
site.name,
model,
scopes,
parsed.filePath,
options.isFileLocalDef,
site.arity,
options.isCallableVisibleFromCaller !== undefined
? (candidate) =>
options.isCallableVisibleFromCaller!({ callerParsed: parsed, candidate })
: undefined,
);
}
if (fnDef === undefined) continue;
const callerGraphId = resolveCallerGraphId(site.inScope, scopes, nodeLookup);
@ -107,6 +125,10 @@ function pickUniqueGlobalCallable(
name: string,
model: SemanticModel,
scopes: ScopeResolutionIndexes,
callerFilePath: string,
isFileLocalDef?: (def: SymbolDefinition) => boolean,
callArity?: number,
isCallerVisible?: (candidate: SymbolDefinition) => boolean,
): SymbolDefinition | undefined {
const scopeDefs: SymbolDefinition[] = [];
const scopeSeen = new Set<string>();
@ -114,6 +136,18 @@ function pickUniqueGlobalCallable(
const simple = def.qualifiedName?.split('.').pop() ?? def.qualifiedName;
if (simple !== name) continue;
if (def.type !== 'Function' && def.type !== 'Method' && def.type !== 'Constructor') continue;
// Skip file-local defs (e.g. C `static` functions) that live in a
// different file from the caller — they are logically invisible.
if (isFileLocalDef !== undefined && def.filePath !== callerFilePath && isFileLocalDef(def)) {
continue;
}
// Caller-side visibility filter (e.g., PHP namespace + use-function
// import gating). When defined, blocks candidates the caller cannot
// legally reach. Languages without namespace-scoped function resolution
// leave this undefined → no filtering.
if (isCallerVisible !== undefined && !isCallerVisible(def)) {
continue;
}
const key = logicalCallableKey(def);
if (scopeSeen.has(key)) continue;
scopeSeen.add(key);
@ -121,10 +155,29 @@ function pickUniqueGlobalCallable(
}
if (scopeDefs.length === 1) return scopeDefs[0];
// When multiple scope-index candidates exist, attempt arity narrowing
// before falling back to the semantic-model lookup. This handles
// registry-primary languages where the model is not populated for the
// migrated language's files (call-processor skips them).
if (scopeDefs.length > 1 && callArity !== undefined) {
const arityMatch = narrowByArity(scopeDefs, callArity);
if (arityMatch !== undefined) return arityMatch;
}
const defs: SymbolDefinition[] = [];
const seen = new Set<string>();
const push = (pool: readonly SymbolDefinition[]): void => {
for (const def of pool) {
// Apply the same file-local linkage filter as Phase 1 —
// cross-file static defs must never leak through the
// SemanticModel fallback path.
if (isFileLocalDef !== undefined && def.filePath !== callerFilePath && isFileLocalDef(def)) {
continue;
}
// Same caller-visibility filter applied to the model-side pool.
if (isCallerVisible !== undefined && !isCallerVisible(def)) {
continue;
}
const key = logicalCallableKey(def);
if (seen.has(key)) continue;
seen.add(key);
@ -135,7 +188,35 @@ function pickUniqueGlobalCallable(
push(model.symbols.lookupCallableByName(name));
push(model.methods.lookupMethodByName(name));
return defs.length === 1 ? defs[0] : undefined;
if (defs.length === 1) return defs[0];
// When multiple candidates exist and the call site has a known arity,
// narrow by parameter count.
if (defs.length > 1 && callArity !== undefined) {
const arityMatch = narrowByArity(defs, callArity);
if (arityMatch !== undefined) return arityMatch;
}
return undefined;
}
/**
* Narrow a list of callable candidates by call-site arity.
* A def is compatible when `requiredParameterCount <= arity <= parameterCount`.
* Defs with `parameterCount === undefined` (variadic/unknown) are always kept.
* Returns the single compatible def, or `undefined` when zero or multiple match.
*/
function narrowByArity(
defs: readonly SymbolDefinition[],
callArity: number,
): SymbolDefinition | undefined {
const compatible = defs.filter((d) => {
const total = d.parameterCount;
if (total === undefined) return true; // unknown arity — keep
const required = d.requiredParameterCount ?? total;
return required <= callArity && callArity <= total;
});
return compatible.length === 1 ? compatible[0] : undefined;
}
function logicalCallableKey(def: SymbolDefinition): string {
@ -167,10 +248,18 @@ function pickConstructorOrClass(
/** Walk up from the call-site scope to the enclosing class scope,
* pick a method member by name with overload narrowing on arity +
* argument types. Returns undefined if there's no enclosing class
* or no matching method. Used for implicit-this calls inside a
* class body where multiple overloads share the call name. */
function pickImplicitThisOverload(
* argument types. Returns undefined if there's no enclosing class,
* no matching method, OR narrowing leaves multiple compatible
* candidates in the multi-candidate case, picking
* `candidates[0]` would emit a high-confidence CALLS edge whose
* target depends on registration order rather than a defensible
* resolution. Mirrors `pickUniqueGlobalCallable`'s uniqueness check
* in the same file (Codex PR #1497 review, finding 2).
*
* Exported for unit testing language-agnostic logic, exercised
* via synthetic stubs in `pick-implicit-this-overload.test.ts`. The
* production call site is `applyFreeCallFallback` immediately above. */
export function pickImplicitThisOverload(
site: {
readonly inScope: ScopeId;
readonly name: string;
@ -203,6 +292,11 @@ function pickImplicitThisOverload(
if (overloads.length === 0) return undefined;
if (overloads.length === 1) return overloads[0];
// Narrow on arity + argument types. Require a UNIQUE survivor —
// ambiguous narrowing (multiple compatible candidates with no
// disambiguating signal) leaves the call unresolved rather than
// routing to an arbitrary first overload by registration order.
const candidates = narrowOverloadCandidates(overloads, site.arity, site.argumentTypes);
if (candidates.length !== 1) return undefined;
return candidates[0];
}

View file

@ -13,9 +13,13 @@
* 2. Exact-required-match wins over variadic. Variadic is detected
* via a `parameterTypes` entry equal to `'params'` or starting
* with `'params '` (C# `params` / variadic marker).
* 3. If the arity filter empties the set, fall back to the full
* overload list rather than returning nothing the caller still
* needs a best-effort candidate.
* 3. If the arity filter empties the set AND any candidate had
* unknown bounds (both `parameterCount` and `requiredParameterCount`
* undefined), fall back to the full overload list the empty
* result may be due to missing metadata rather than a real mismatch.
* If EVERY rejected candidate had definite arity bounds, trust the
* filter and return empty the call is genuinely arity-incompatible
* (e.g., PHP `f(int $req, ...$rest)` called with zero args).
* 4. If `argTypes` is present, filter further by per-slot type
* equality. An empty string in `argTypes[i]` means "unknown" and
* counts as a match. Mismatches disqualify. A non-empty typed
@ -39,6 +43,16 @@ export function narrowOverloadCandidates(
const max = d.parameterCount;
const min = d.requiredParameterCount;
if (max !== undefined && argCount > max) {
// Variadic marker check is C#-specific (the 'params' keyword).
// Other languages use their own marker — PHP uses '...' (see
// `languages/php/arity-metadata.ts:46`), Python uses '*args'-
// shaped metadata that lives outside `parameterTypes` entirely.
// This branch is dead code for those languages because they
// set `parameterCount = undefined` for variadic functions,
// which keeps `max` undefined and skips this check entirely.
// Adding new variadic markers here changes behavior for those
// other languages too — don't extend without auditing each
// adapter's `arity-metadata.ts`. Finding 9 of PR #1497.
const variadic =
d.parameterTypes !== undefined &&
d.parameterTypes.some((t) => t === 'params' || t.startsWith('params '));
@ -48,8 +62,16 @@ export function narrowOverloadCandidates(
return true;
});
// When the arity filter empties the set, only fall back to the full
// overload list if some candidate had unknown bounds — otherwise the
// empty result is authoritative (every candidate definitively failed
// arity, e.g., PHP variadic with required-prefix called with too few
// args).
const anyUnknownBounds = overloads.some(
(d) => d.parameterCount === undefined && d.requiredParameterCount === undefined,
);
const candidates: readonly SymbolDefinition[] =
arityMatches.length > 0 ? arityMatches : overloads;
arityMatches.length > 0 ? arityMatches : anyUnknownBounds ? overloads : [];
if (argTypes !== undefined && argTypes.length > 0) {
const typed = candidates.filter((d) => {

View file

@ -165,7 +165,16 @@ export function emitReceiverBoundCalls(
if (provider.isSuperReceiver(receiverName)) {
const enclosingClass = findEnclosingClassDef(site.inScope, scopes);
if (enclosingClass !== undefined) {
const ancestors = scopes.methodDispatch.mroFor(enclosingClass.nodeId);
// For super-receiver dispatch (`parent::`, `base.`, `super()`),
// walk the inheritance-only ancestor chain when the language
// exposes it. PHP's `parent::` semantically bypasses composed
// traits; other languages without mixin augmentation have no
// `extendsOnlyMroFor` and fall back to `mroFor`.
const extendsOnly = scopes.methodDispatch.extendsOnlyMroFor;
const ancestors =
extendsOnly !== undefined
? extendsOnly(enclosingClass.nodeId)
: scopes.methodDispatch.mroFor(enclosingClass.nodeId);
let memberDef: SymbolDefinition | undefined;
for (const ownerId of ancestors) {
memberDef = findOwnedMember(ownerId, memberName, model);
@ -283,7 +292,21 @@ export function emitReceiverBoundCalls(
let memberDef: SymbolDefinition | undefined;
for (const ownerId of chain) {
memberDef = findOwnedMember(ownerId, memberName, model);
if (memberDef !== undefined) break;
if (memberDef !== undefined) {
// The MRO chain is most-derived-first ([classDef, ...ancestors]).
// If the most-derived definition is arity-incompatible with the
// call site, PHP throws ArgumentCountError at runtime — it does
// NOT silently dispatch to an ancestor. Terminate the chain walk
// so no edge is emitted, rather than falling through to an
// arity-compatible ancestor (which would be a false positive).
if (
narrowOverloadCandidates([memberDef], site.arity, site.argumentTypes).length === 0
) {
memberDef = undefined;
break;
}
break;
}
}
if (memberDef !== undefined) {
const reason =

View file

@ -93,13 +93,25 @@ export const scopeResolutionPhase: PipelinePhase<ScopeResolutionOutput> = {
// Worker-mode parses leave the cache empty for those files; they
// also fall back to a fresh parse — no correctness impact.
const parseOutput = getPhaseOutput<ParseOutput>(deps, 'parse');
const { scopeTreeCache, resolutionContext } = parseOutput;
const { scopeTreeCache, resolutionContext, parsedFiles: workerParsedFiles } = parseOutput;
// SemanticModel populated during `parse`: scope-resolution consumes
// TypeRegistry / MethodRegistry / SymbolTable lookups instead of
// rebuilding parallel indexes. See ARCHITECTURE.md § "Semantic-model
// source of truth".
const model = resolutionContext.model;
// Build a per-file lookup of ParsedFile artifacts the workers (or
// sequential extracts) already produced. Threading this into
// `runScopeResolution` lets the per-language extract loop short-
// circuit `extractParsedFile` — the dominant cost on the warm-cache
// path, since workers can't return tree-sitter Trees across the
// MessageChannel and scope-resolution would otherwise re-parse
// every file from scratch on the main thread.
const preExtractedByPath = new Map<string, import('gitnexus-shared').ParsedFile>();
for (const pf of workerParsedFiles) {
preExtractedByPath.set(pf.filePath, pf);
}
let totalFiles = 0;
let totalImports = 0;
let totalRefs = 0;
@ -143,6 +155,7 @@ export const scopeResolutionPhase: PipelinePhase<ScopeResolutionOutput> = {
files,
treeCache: scopeTreeCache,
resolutionConfig,
preExtractedParsedFiles: preExtractedByPath,
onWarn: (msg) => {
if (isSemanticModelValidatorEnabled()) {
logger.warn(`[scope-resolution:${lang}] ${msg}`);

View file

@ -15,6 +15,9 @@ import { pythonScopeResolver } from '../../languages/python/scope-resolver.js';
import { csharpScopeResolver } from '../../languages/csharp/scope-resolver.js';
import { typescriptScopeResolver } from '../../languages/typescript/scope-resolver.js';
import { goScopeResolver } from '../../languages/go/scope-resolver.js';
import { javaScopeResolver } from '../../languages/java/scope-resolver.js';
import { cScopeResolver } from '../../languages/c/scope-resolver.js';
import { phpScopeResolver } from '../../languages/php/scope-resolver.js';
/** Map of `SupportedLanguages` `ScopeResolver`. The phase iterates
* this map intersected with `MIGRATED_LANGUAGES` (the per-language
@ -28,4 +31,7 @@ export const SCOPE_RESOLVERS: ReadonlyMap<SupportedLanguages, ScopeResolver> = n
[SupportedLanguages.CSharp, csharpScopeResolver],
[SupportedLanguages.TypeScript, typescriptScopeResolver],
[SupportedLanguages.Go, goScopeResolver],
[SupportedLanguages.Java, javaScopeResolver],
[SupportedLanguages.C, cScopeResolver],
[SupportedLanguages.PHP, phpScopeResolver],
]);

View file

@ -72,6 +72,22 @@ interface RunScopeResolutionInput {
* provider doesn't supply a config loader.
*/
readonly resolutionConfig?: unknown;
/**
* Pre-extracted ParsedFile artifacts keyed by file path. When a
* file is present here, the extract loop reuses it directly and
* skips `extractParsedFile` (which would re-parse the file with
* tree-sitter on the main thread). Only files matching the
* provider's language are honored the loop verifies this
* implicitly by language filter at the call-site (scopeResolution
* phase).
*
* Worker-mode parses produce these ParsedFile artifacts as a side
* effect of `extractParsedFile` running inside the worker; threading
* them here is what lets the warm-cache analyze run skip the ~58s
* scope-resolution re-parse loop on a multi-thousand-file repo.
* Cache miss is safe falls back to fresh extract.
*/
readonly preExtractedParsedFiles?: ReadonlyMap<string, ParsedFile>;
}
interface RunScopeResolutionStats {
@ -104,22 +120,37 @@ export function runScopeResolution(
const parsedFiles: ParsedFile[] = [];
let filesSkipped = 0;
const treeCache = input.treeCache;
const preExtracted = input.preExtractedParsedFiles;
let preExtractedHits = 0;
for (const file of files) {
const cachedTree = treeCache?.get(file.path);
const parsed = extractParsedFile(
provider.languageProvider,
file.content,
file.path,
onWarn,
cachedTree,
);
let parsed: ParsedFile | undefined;
// Fast path: a worker (during the parse phase) already produced a
// ParsedFile for this file via `extractParsedFile`. Reuse it
// directly — skips a tree-sitter re-parse on the main thread.
if (preExtracted !== undefined) {
parsed = preExtracted.get(file.path);
if (parsed !== undefined) preExtractedHits++;
}
if (parsed === undefined) {
filesSkipped++;
continue;
const cachedTree = treeCache?.get(file.path);
parsed = extractParsedFile(
provider.languageProvider,
file.content,
file.path,
onWarn,
cachedTree,
);
if (parsed === undefined) {
filesSkipped++;
continue;
}
}
provider.populateOwners(parsed);
parsedFiles.push(parsed);
}
if (PROF && preExtracted !== undefined) {
logger.warn(`[scope-resolution prof] pre-extracted hits: ${preExtractedHits}/${files.length}`);
}
provider.populateWorkspaceOwners?.(parsedFiles, { fileContents: getFileContents() });
// Reconcile scope-resolution's ownership view into the SemanticModel.
@ -153,6 +184,7 @@ export function runScopeResolution(
const allFilePaths = new Set(parsedFiles.map((f) => f.filePath));
const nodeLookup = buildGraphNodeLookup(graph);
const mroByClassDefId = provider.buildMro(graph, parsedFiles, nodeLookup);
const extendsOnlyMroByClassDefId = provider.buildExtendsOnlyMro?.(graph, parsedFiles, nodeLookup);
const resolutionConfig = input.resolutionConfig;
const finalized = finalizeScopeModel(parsedFiles, {
@ -174,7 +206,7 @@ export function runScopeResolution(
// the type system.
const indexes = {
...finalized,
methodDispatch: buildPopulatedMethodDispatch(mroByClassDefId),
methodDispatch: buildPopulatedMethodDispatch(mroByClassDefId, extendsOnlyMroByClassDefId),
};
// Build the workspace resolution index ONCE — scope-valued lookups
@ -252,6 +284,17 @@ export function runScopeResolution(
workspaceIndex,
readonlyModel,
);
const unresolvedReceiverExtras =
provider.emitUnresolvedReceiverEdges !== undefined
? provider.emitUnresolvedReceiverEdges(
graph,
indexes,
parsedFiles,
nodeLookup,
handledSites,
readonlyModel,
)
: 0;
const freeCallExtras = emitFreeCallFallback(
graph,
indexes,
@ -261,7 +304,11 @@ export function runScopeResolution(
handledSites,
readonlyModel,
workspaceIndex,
{ allowGlobalFallback: provider.allowGlobalFreeCallFallback === true },
{
allowGlobalFallback: provider.allowGlobalFreeCallFallback === true,
isFileLocalDef: provider.isFileLocalDef,
isCallableVisibleFromCaller: provider.isCallableVisibleFromCaller,
},
);
const { emitted, skipped } = emitReferencesViaLookup(
graph,
@ -296,7 +343,7 @@ export function runScopeResolution(
filesSkipped,
importsEmitted,
resolve: resolveStats,
referenceEdgesEmitted: emitted + receiverExtras + freeCallExtras,
referenceEdgesEmitted: emitted + receiverExtras + unresolvedReceiverExtras + freeCallExtras,
referenceSkipped: skipped,
};
}

View file

@ -1020,6 +1020,16 @@ export const PHP_QUERIES = `
(use_declaration
[(name) (qualified_name)] @heritage.trait))) @heritage
; Heritage: trait uses another trait (transitive trait composition)
; PHP allows a trait body to contain "use OtherTrait;". The trait-uses-trait
; IMPLEMENTS edge is required by buildPhpMro to compute the full transitive
; trait closure (depth 3+ chains).
(trait_declaration
name: (name) @heritage.class
body: (declaration_list
(use_declaration
[(name) (qualified_name)] @heritage.trait))) @heritage
; PHP HTTP consumers: file_get_contents('/path'), curl_init('/path')
(function_call_expression
function: (name) @_php_http (#match? @_php_http "^(file_get_contents|curl_init)$")

View file

@ -20,6 +20,7 @@ import {
getTreeSitterContentByteLength,
TREE_SITTER_MAX_BUFFER,
} from '../constants.js';
import { parseSourceSafe } from '../../tree-sitter/safe-parse.js';
import type { SymbolTableReader } from '../model/symbol-table.js';
import type { ExtractedHeritage } from '../model/heritage-map.js';
@ -1416,7 +1417,7 @@ const processFileGroup = (
let tree;
try {
tree = parser.parse(parseContent, undefined, {
tree = parseSourceSafe(parser, parseContent, undefined, {
bufferSize: getTreeSitterBufferSize(parseContent),
});
} catch (err) {

View file

@ -218,6 +218,65 @@ const runWithSessionLock = async <T>(operation: () => Promise<T>): Promise<T> =>
const normalizeCopyPath = (filePath: string): string => filePath.replace(/\\/g, '/');
const closeQueryResult = async (result: lbug.QueryResult): Promise<void> => {
try {
await result.close();
} catch {
// Best-effort cleanup only.
}
};
const drainQueryResult = async (
queryResult: lbug.QueryResult | lbug.QueryResult[],
): Promise<void> => {
const results = Array.isArray(queryResult) ? queryResult : [queryResult];
let firstError: unknown;
let hasError = false;
for (const result of results) {
try {
await result.getAll();
} catch (err) {
if (!hasError) {
firstError = err;
hasError = true;
}
} finally {
await closeQueryResult(result);
}
}
if (hasError) throw firstError;
};
const readQueryRows = async (
queryResult: lbug.QueryResult | lbug.QueryResult[],
): Promise<any[]> => {
const results = Array.isArray(queryResult) ? queryResult : [queryResult];
let rows: any[] = [];
let firstError: unknown;
let hasError = false;
for (let i = 0; i < results.length; i++) {
const result = results[i];
try {
const resultRows = await result.getAll();
if (i === 0) rows = resultRows;
} catch (err) {
if (!hasError) {
firstError = err;
hasError = true;
}
} finally {
await closeQueryResult(result);
}
}
if (hasError) throw firstError;
return rows;
};
const queryAndDrain = async (targetConn: lbug.Connection, cypher: string): Promise<void> => {
const queryResult = await targetConn.query(cypher);
await drainQueryResult(queryResult);
};
export const initLbug = async (dbPath: string) => {
return runWithSessionLock(() => ensureLbugInitialized(dbPath));
};
@ -319,7 +378,7 @@ const doInitLbug = async (dbPath: string) => {
for (const schemaQuery of SCHEMA_QUERIES) {
try {
await conn.query(schemaQuery);
await queryAndDrain(conn, schemaQuery);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
// Suppression list:
@ -384,14 +443,14 @@ export const loadGraphToLbug = async (
const copyQuery = getCopyQuery(table, normalizedPath);
try {
await conn.query(copyQuery);
await queryAndDrain(conn, copyQuery);
} catch (err) {
try {
const retryQuery = copyQuery.replace(
'auto_detect=false)',
'auto_detect=false, IGNORE_ERRORS=true)',
);
await conn.query(retryQuery);
await queryAndDrain(conn, retryQuery);
} catch (retryErr) {
const retryMsg = retryErr instanceof Error ? retryErr.message : String(retryErr);
throw new Error(`COPY failed for ${table}: ${retryMsg.slice(0, 200)}`);
@ -433,14 +492,14 @@ export const loadGraphToLbug = async (
}
try {
await conn.query(copyQuery);
await queryAndDrain(conn, copyQuery);
} catch (err) {
try {
const retryQuery = copyQuery.replace(
'auto_detect=false)',
'auto_detect=false, IGNORE_ERRORS=true)',
);
await conn.query(retryQuery);
await queryAndDrain(conn, retryQuery);
} catch (retryErr) {
const retryMsg = retryErr instanceof Error ? retryErr.message : String(retryErr);
warnings.push(`${fromLabel}->${toLabel} (${rows} edges): ${retryMsg.slice(0, 80)}`);
@ -562,11 +621,14 @@ const fallbackRelationshipInserts = async (
const esc = (s: string) =>
s.replace(/'/g, "''").replace(/\\/g, '\\\\').replace(/\n/g, '\\n').replace(/\r/g, '\\r');
await conn.query(`
await queryAndDrain(
conn,
`
MATCH (a:${escapeLabel(fromLabel)} {id: '${esc(fromId)}' }),
(b:${escapeLabel(toLabel)} {id: '${esc(toId)}' })
CREATE (a)-[:${REL_TABLE_NAME} {type: '${esc(relType)}', confidence: ${confidence}, reason: '${esc(reason)}', step: ${step}}]->(b)
`);
`,
);
} catch {
// skip
}
@ -679,14 +741,14 @@ export const insertNodeToLbug = async (
if (targetDbPath) {
const tempHandle = await openLbugConnection(lbug, targetDbPath);
try {
await tempHandle.conn.query(query);
await queryAndDrain(tempHandle.conn, query);
return true;
} finally {
await closeLbugConnection(tempHandle);
}
} else if (conn) {
// Use existing persistent connection (when called from analyze)
await conn.query(query);
await queryAndDrain(conn, query);
return true;
}
@ -757,7 +819,7 @@ export const batchInsertNodesToLbug = async (
query = `MERGE (n:${t} {id: ${escapeValue(properties.id)}}) SET n.name = ${escapeValue(properties.name)}, n.filePath = ${escapeValue(properties.filePath)}, n.startLine = ${properties.startLine || 0}, n.endLine = ${properties.endLine || 0}, n.content = ${escapeValue(properties.content || '')}${descPart}`;
}
await tempConn.query(query);
await queryAndDrain(tempConn, query);
inserted++;
} catch (e: any) {
// Don't console.error here - it corrupts MCP JSON-RPC on stderr
@ -777,11 +839,7 @@ export const executeQuery = async (cypher: string): Promise<any[]> => {
}
const queryResult = await conn.query(cypher);
// LadybugDB uses getAll() instead of hasNext()/getNext()
// Query returns QueryResult for single queries, QueryResult[] for multi-statement
const result = Array.isArray(queryResult) ? queryResult[0] : queryResult;
const rows = await result.getAll();
return rows;
return await readQueryRows(queryResult);
};
export const streamQuery = async (
@ -793,8 +851,10 @@ export const streamQuery = async (
}
const queryResult = await conn.query(cypher);
const result = Array.isArray(queryResult) ? queryResult[0] : queryResult;
const results = Array.isArray(queryResult) ? queryResult : [queryResult];
const result = results[0];
let rowCount = 0;
let streamError: unknown;
try {
while (await result.hasNext()) {
@ -803,11 +863,14 @@ export const streamQuery = async (
rowCount++;
}
return rowCount;
} catch (err) {
streamError = err;
throw err;
} finally {
try {
await result.close();
} catch {
// Best-effort cleanup only.
await drainQueryResult(results);
} catch (err) {
if (streamError === undefined) throw err;
}
}
};
@ -829,8 +892,7 @@ export const executePrepared = async (
throw new Error(`Prepare failed: ${errMsg}`);
}
const queryResult = await conn.execute(stmt, params);
const result = Array.isArray(queryResult) ? queryResult[0] : queryResult;
return await result.getAll();
return await readQueryRows(queryResult);
};
export const executeWithReusedStatement = async (
@ -852,7 +914,7 @@ export const executeWithReusedStatement = async (
}
try {
for (const params of subBatch) {
await conn.execute(stmt, params);
await drainQueryResult(await conn.execute(stmt, params));
}
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
@ -874,8 +936,7 @@ export const getLbugStats = async (): Promise<{ nodes: number; edges: number }>
const queryResult = await conn.query(
`MATCH (n:${escapeTableName(tableName)}) RETURN count(n) AS cnt`,
);
const nodeResult = Array.isArray(queryResult) ? queryResult[0] : queryResult;
const nodeRows = await nodeResult.getAll();
const nodeRows = await readQueryRows(queryResult);
if (nodeRows.length > 0) {
totalNodes += Number(nodeRows[0]?.cnt ?? nodeRows[0]?.[0] ?? 0);
}
@ -889,8 +950,7 @@ export const getLbugStats = async (): Promise<{ nodes: number; edges: number }>
const queryResult = await conn.query(
`MATCH ()-[r:${REL_TABLE_NAME}]->() RETURN count(r) AS cnt`,
);
const edgeResult = Array.isArray(queryResult) ? queryResult[0] : queryResult;
const edgeRows = await edgeResult.getAll();
const edgeRows = await readQueryRows(queryResult);
if (edgeRows.length > 0) {
totalEdges = Number(edgeRows[0]?.cnt ?? edgeRows[0]?.[0] ?? 0);
}
@ -926,8 +986,7 @@ export const loadCachedEmbeddings = async (): Promise<{
const check = await conn.query(
`MATCH (e:${EMBEDDING_TABLE_NAME}) RETURN e.nodeId AS nodeId, e.chunkIndex AS chunkIndex LIMIT 1`,
);
const checkResult = Array.isArray(check) ? check[0] : check;
await checkResult.getAll();
await readQueryRows(check);
} catch {
return { embeddingNodeIds: new Set(), embeddings: [] };
}
@ -951,8 +1010,7 @@ export const loadCachedEmbeddings = async (): Promise<{
throw err;
}
}
const result = Array.isArray(rows) ? rows[0] : rows;
for (const row of await result.getAll()) {
for (const row of await readQueryRows(rows)) {
const nodeId = String(row.nodeId ?? row[0] ?? '');
if (!nodeId) continue;
embeddingNodeIds.add(nodeId);
@ -1060,7 +1118,8 @@ export const fetchExistingEmbeddingHashes = async (
export const flushWAL = async (): Promise<void> => {
if (!conn) return;
try {
await conn.query('CHECKPOINT');
const checkpointResult = await conn.query('CHECKPOINT');
await drainQueryResult(checkpointResult);
} catch {
/* ignore — older LadybugDB or schemaless DB may not accept it */
}
@ -1170,13 +1229,13 @@ export const deleteNodesForFile = async (
const countResult = await targetConn!.query(
`MATCH (n:${tn}) WHERE n.filePath = '${escapedPath}' RETURN count(n) AS cnt`,
);
const result = Array.isArray(countResult) ? countResult[0] : countResult;
const rows = await result.getAll();
const rows = await readQueryRows(countResult);
const count = Number(rows[0]?.cnt ?? rows[0]?.[0] ?? 0);
if (count > 0) {
// Delete nodes (and implicitly their relationships via DETACH)
await targetConn!.query(
await queryAndDrain(
targetConn!,
`MATCH (n:${tn}) WHERE n.filePath = '${escapedPath}' DETACH DELETE n`,
);
deletedNodes += count;
@ -1188,7 +1247,8 @@ export const deleteNodesForFile = async (
// Also delete any embeddings for nodes in this file
try {
await targetConn!.query(
await queryAndDrain(
targetConn!,
`MATCH (e:${EMBEDDING_TABLE_NAME}) WHERE e.nodeId STARTS WITH '${escapedPath}' DELETE e`,
);
} catch {
@ -1204,6 +1264,77 @@ export const deleteNodesForFile = async (
export const getEmbeddingTableName = (): string => EMBEDDING_TABLE_NAME;
/**
* Return the distinct repo-relative paths of files that import
* `targetFilePath` according to the IMPORTS edges currently in the
* DB. Used by the incremental writeback path to expand the
* "files-to-rewrite" set so that files importing a changed file get
* their edges (which may have been refined by cross-file resolution)
* re-emitted, rather than left stale in the DB.
*
* The DB query reads the *previous* run's state pre-pipeline, before
* any nodes are deleted so the returned importers are "files that
* USED TO import the target". That's the right set to invalidate:
* those are the files whose edges in the DB might no longer match
* what cross-file resolution produces given the changed file's new
* exports.
*/
export const queryImporters = async (targetFilePath: string): Promise<string[]> => {
if (!conn) {
throw new Error('LadybugDB not initialized. Call initLbug first.');
}
const escaped = targetFilePath.replace(/'/g, "''");
const cypher = `
MATCH (a)-[r:${REL_TABLE_NAME}]->(b)
WHERE r.type = 'IMPORTS' AND b.filePath = '${escaped}'
RETURN DISTINCT a.filePath AS importer
`;
try {
const queryResult = await conn.query(cypher);
const result = Array.isArray(queryResult) ? queryResult[0] : queryResult;
const rows = await result.getAll();
const out: string[] = [];
for (const row of rows) {
const v = (row as { importer?: unknown }).importer;
if (typeof v === 'string' && v.length > 0) out.push(v);
}
return out;
} catch {
return [];
}
};
/**
* Drop every Community and Process node (and their MEMBER_OF /
* STEP_IN_PROCESS edges via DETACH DELETE). Used at the start of an
* incremental run so the communities and processes phases regenerate
* them from scratch on the merged graph required for the
* "Leiden runs on the FULL graph" correctness invariant.
*/
export const deleteAllCommunitiesAndProcesses = async (): Promise<{
nodesDeleted: number;
}> => {
if (!conn) {
throw new Error('LadybugDB not initialized. Call initLbug first.');
}
let nodesDeleted = 0;
for (const label of ['Community', 'Process']) {
try {
const countResult = await conn.query(`MATCH (n:${label}) RETURN count(n) AS cnt`);
const result = Array.isArray(countResult) ? countResult[0] : countResult;
const rows = await result.getAll();
const count = Number(rows[0]?.cnt ?? rows[0]?.[0] ?? 0);
if (count > 0) {
await conn.query(`MATCH (n:${label}) DETACH DELETE n`);
nodesDeleted += count;
}
} catch {
// Table may not exist yet on a freshly-initialized DB — fine.
}
}
return { nodesDeleted };
};
// ============================================================================
// Full-Text Search (FTS) Functions
// ============================================================================
@ -1232,7 +1363,7 @@ export const loadFTSExtension = async (
throw new Error('LadybugDB not initialized. Call initLbug first.');
}
const loaded = await extensionManager.ensure((sql) => c.query(sql), 'fts', 'FTS', opts);
const loaded = await extensionManager.ensure((sql) => queryAndDrain(c, sql), 'fts', 'FTS', opts);
if (loaded && useModuleState) ftsLoaded = true;
return loaded;
};
@ -1248,6 +1379,13 @@ export const loadVectorExtension = async (
): Promise<boolean> => {
const useModuleState = targetConn === undefined;
if (useModuleState && vectorExtensionLoaded) return true;
// INSTALL VECTOR crashes with SIGSEGV on Windows: the KuzuDB native extension
// installer has an unhandled error path on Windows that raises a fatal signal
// that JS try/catch cannot intercept. Skip loading — vector/embedding search
// is unavailable but all graph index queries still work. Do NOT set
// vectorExtensionLoaded here: the flag means "successfully loaded", and a
// subsequent call would otherwise short-circuit to `return true` at the top.
if (process.platform === 'win32') return false;
if (!isVectorExtensionSupportedByPlatform()) return false;
const c: lbug.Connection | null = targetConn ?? conn;
@ -1255,7 +1393,12 @@ export const loadVectorExtension = async (
throw new Error('LadybugDB not initialized. Call initLbug first.');
}
const loaded = await extensionManager.ensure((sql) => c.query(sql), 'VECTOR', 'VECTOR', opts);
const loaded = await extensionManager.ensure(
(sql) => queryAndDrain(c, sql),
'VECTOR',
'VECTOR',
opts,
);
if (loaded && useModuleState) vectorExtensionLoaded = true;
return loaded;
};
@ -1287,7 +1430,7 @@ export const createFTSIndex = async (
const query = `CALL CREATE_FTS_INDEX('${tableName}', '${indexName}', [${propList}], stemmer := '${stemmer}')`;
try {
await conn.query(query);
await queryAndDrain(conn, query);
ensuredFTSIndexes.add(key);
} catch (e: any) {
if (e.message?.includes('already exists')) {
@ -1371,8 +1514,7 @@ export const queryFTS = async (
try {
const queryResult = await conn.query(cypher);
const result = Array.isArray(queryResult) ? queryResult[0] : queryResult;
const rows = await result.getAll();
const rows = await readQueryRows(queryResult);
return rows.map((row: any) => {
const node = row.node || row[0] || {};
@ -1403,7 +1545,7 @@ export const dropFTSIndex = async (tableName: string, indexName: string): Promis
}
try {
await conn.query(`CALL DROP_FTS_INDEX('${tableName}', '${indexName}')`);
await queryAndDrain(conn, `CALL DROP_FTS_INDEX('${tableName}', '${indexName}')`);
} catch {
// Index may not exist
} finally {

View file

@ -420,7 +420,17 @@ async function doInitLbug(repoId: string, dbPath: string): Promise<void> {
// install; analyze owns extension installation. If LOAD fails, search
// features degrade gracefully and the user-facing query path proceeds.
if (!shared.ftsLoaded) {
shared.ftsLoaded = await loadFTSExtension(available[0], { policy: 'load-only' });
// Windows guard: LOAD EXTENSION fts crashes with SIGSEGV on Windows when
// the FTS extension binary is not installed locally (@ladybugdb/core native
// bug — the extension loader hits an unhandled error path that signals SIGSEGV
// rather than throwing a JS exception, so try/catch cannot protect here).
// Skip the load on Windows; bm25-index.js catches the resulting Kuzu catalog
// errors and returns empty BM25 results gracefully. Graph queries are unaffected.
if (process.platform === 'win32') {
shared.ftsLoaded = true;
} else {
shared.ftsLoaded = await loadFTSExtension(available[0], { policy: 'load-only' });
}
}
// Register pool entry only after all connections are pre-warmed and FTS is
@ -484,8 +494,13 @@ export async function initLbugWithDb(
// Load FTS extension if not already loaded on this Database.
// policy: 'load-only' — same contract as initLbug above; the read pool
// must not block on a network install during query execution.
// Windows guard: same SIGSEGV risk as doInitLbug above — skip on Windows.
if (!shared.ftsLoaded) {
shared.ftsLoaded = await loadFTSExtension(available[0], { policy: 'load-only' });
if (process.platform === 'win32') {
shared.ftsLoaded = true;
} else {
shared.ftsLoaded = await loadFTSExtension(available[0], { policy: 'load-only' });
}
}
pool.set(repoId, {

View file

@ -11,6 +11,7 @@
import path from 'path';
import fs from 'fs/promises';
import { execFileSync } from 'child_process';
import { runPipelineFromRepo } from './ingestion/pipeline.js';
import {
initLbug,
@ -20,6 +21,9 @@ import {
executeWithReusedStatement,
closeLbug,
loadCachedEmbeddings,
deleteNodesForFile,
deleteAllCommunitiesAndProcesses,
queryImporters,
} from './lbug/lbug-adapter.js';
import { createSearchFTSIndexes } from './search/fts-indexes.js';
import {
@ -29,7 +33,15 @@ import {
ensureGitNexusIgnored,
registerRepo,
cleanupOldKuzuFiles,
INCREMENTAL_SCHEMA_VERSION,
} from '../storage/repo-manager.js';
import { computeFileHashes, diffFileHashes } from '../storage/file-hash.js';
import {
extractChangedSubgraph,
computeEffectiveWriteSet,
} from './incremental/subgraph-extract.js';
import { shadowCandidatesFor } from './incremental/shadow-candidates.js';
import { loadParseCache, saveParseCache, pruneCache } from '../storage/parse-cache.js';
import {
getCurrentCommit,
getRemoteUrl,
@ -81,6 +93,8 @@ export interface AnalyzeOptions {
skipAgentsMd?: boolean;
/** Omit volatile symbol/relationship counts from AGENTS.md and CLAUDE.md. */
noStats?: boolean;
/** Skip installing standard GitNexus skill files to .claude/skills/gitnexus/. */
skipSkills?: boolean;
/**
* User-provided alias for the registry `name` (#829). When set,
* forwarded to `registerRepo` so the indexed repo is stored under
@ -176,23 +190,81 @@ export async function runFullAnalysis(
const currentCommit = repoHasGit ? getCurrentCommit(repoPath) : '';
const existingMeta = await loadMeta(storagePath);
// ── Crash recovery: dirty flag forces full rebuild ────────────────
// If the previous incremental run set incrementalInProgress and didn't
// clear it, the on-disk index may be in a half-state. Cheapest path
// back to a known-good index is to wipe + rebuild from scratch.
if (existingMeta?.incrementalInProgress) {
log(
'Previous incremental run did not complete cleanly (incrementalInProgress flag set); ' +
'forcing full rebuild to restore a known-good index.',
);
options = { ...options, force: true };
// Reload meta after clearing the flag in-memory; we still want fileHashes
// for the post-rebuild meta carry-over, but force=true ensures the
// rebuild path executes.
}
// ── Early-return: already up to date ──────────────────────────────
if (existingMeta && !options.force && existingMeta.lastCommit === currentCommit) {
// Non-git folders have currentCommit = '' — always rebuild since we can't detect changes
if (currentCommit !== '') {
await ensureGitNexusIgnored(repoPath);
return {
// `resolveRepoIdentityRoot` collapses worktree roots to the
// canonical repo basename (#1259) but leaves arbitrary subdirs
// and `--skip-git` paths unchanged (#1232/#1233 intent preserved).
repoName:
options.registryName ??
getInferredRepoName(repoPath) ??
path.basename(resolveRepoIdentityRoot(repoPath)),
repoPath,
stats: existingMeta.stats ?? {},
alreadyUpToDate: true,
};
// For git repos, even if HEAD matches lastCommit, the working tree
// may have uncommitted changes. Only short-circuit when the working
// tree is also clean — otherwise fall through to the incremental
// path which will hash-diff and update only changed files.
//
// We exclude paths that GitNexus itself writes during analyze:
// .gitnexus/ — db / parse cache / meta.json
// .claude/, .cursor/ — auto-generated agent skill files
// AGENTS.md, CLAUDE.md — auto-updated stats blocks
// Counting them as dirty would perpetually defeat the up-to-date
// fast path because the previous analyze just wrote them
// (regression vs PR #1233 behavior).
const dirty = (() => {
try {
const out = execFileSync(
'git',
[
'status',
'--porcelain',
'--',
'.',
':(exclude).gitnexus',
':(exclude).gitnexus/**',
':(exclude).claude',
':(exclude).claude/**',
':(exclude).cursor',
':(exclude).cursor/**',
':(exclude)AGENTS.md',
':(exclude)CLAUDE.md',
],
{
cwd: repoPath,
stdio: ['ignore', 'pipe', 'ignore'],
encoding: 'utf8',
},
);
return out.trim().length > 0;
} catch {
return true; // conservative on git failure
}
})();
if (!dirty) {
await ensureGitNexusIgnored(repoPath);
return {
// `resolveRepoIdentityRoot` collapses worktree roots to the
// canonical repo basename (#1259) but leaves arbitrary subdirs
// and `--skip-git` paths unchanged (#1232/#1233 intent preserved).
repoName:
options.registryName ??
getInferredRepoName(repoPath) ??
path.basename(resolveRepoIdentityRoot(repoPath)),
repoPath,
stats: existingMeta.stats ?? {},
alreadyUpToDate: true,
};
}
}
}
@ -241,6 +313,14 @@ export async function runFullAnalysis(
);
}
// We *always* load the embedding cache when one is requested (regardless
// of the predicted `willTryIncremental`). The post-pipeline branch may
// disagree with the prediction (e.g. when the pipeline produces zero
// File nodes, `isIncremental` flips false and the full-rebuild path
// wipes the DB) — loading unconditionally is cheap insurance against
// silently dropping embeddings on a mispredicted run. The re-insert
// step gates itself on the actual `isIncremental` value to avoid
// PK-conflicts when the incremental writeback path keeps the rows.
if (shouldLoadCache && existingMeta) {
try {
progress('embeddings', 0, 'Caching embeddings...');
@ -268,24 +348,89 @@ export async function runFullAnalysis(
}
}
// ── Load incremental parse cache ──────────────────────────────────
// Content-addressed: safe to reuse across `--force` runs (chunks whose
// file contents haven't changed produce identical worker output).
// Loaded into a single ParseCache object that the pipeline mutates
// in-place (cache hits leave entries unchanged; misses add new ones).
const parseCache = await loadParseCache(storagePath);
// ── Phase 1: Full Pipeline (060%) ────────────────────────────────
const pipelineResult = await runPipelineFromRepo(repoPath, (p) => {
const phaseLabel = PHASE_LABELS[p.phase] || p.phase;
const scaled = Math.round(p.percent * 0.6);
const message = p.detail ? `${p.message || phaseLabel} (${p.detail})` : p.message || phaseLabel;
progress(p.phase, scaled, message);
});
const pipelineResult = await runPipelineFromRepo(
repoPath,
(p) => {
const phaseLabel = PHASE_LABELS[p.phase] || p.phase;
const scaled = Math.round(p.percent * 0.6);
const message = p.detail
? `${p.message || phaseLabel} (${p.detail})`
: p.message || phaseLabel;
progress(p.phase, scaled, message);
},
{ parseCache },
);
// ── Phase 2: LadybugDB (6085%) ──────────────────────────────────
progress('lbug', 60, 'Loading into LadybugDB...');
await closeLbug();
const lbugFiles = [lbugPath, `${lbugPath}.wal`, `${lbugPath}.lock`];
for (const f of lbugFiles) {
try {
await fs.rm(f, { recursive: true, force: true });
} catch {
/* swallow */
// Compute current per-file content hashes from the pipeline's File nodes.
// Used both to drive the incremental DB writeback (when eligible) and to
// populate meta.json.fileHashes for the next run.
const allFilePaths: string[] = [];
pipelineResult.graph.forEachNode((n) => {
if (n.label === 'File') {
const fp = n.properties?.filePath as string | undefined;
if (fp) allFilePaths.push(fp);
}
});
const newFileHashes = await computeFileHashes(repoPath, allFilePaths);
// Decide incremental vs full at THIS point (post-pipeline, pre-DB).
// All eligibility conditions are checked here against the actual
// pipeline output — no separate pre-pipeline prediction to desync from
// (Bugbot review on PR #1479: a prediction that flipped post-pipeline
// could skip the embedding cache load and then take the full-rebuild
// path, silently losing embeddings).
const isIncremental =
!options.force &&
!!existingMeta &&
existingMeta.schemaVersion === INCREMENTAL_SCHEMA_VERSION &&
!!existingMeta.fileHashes &&
Object.keys(existingMeta.fileHashes).length > 0 &&
repoHasGit &&
allFilePaths.length > 0;
const hashDiff = isIncremental
? diffFileHashes(newFileHashes, existingMeta!.fileHashes)
: undefined;
if (isIncremental && hashDiff) {
log(
`Incremental: changed=${hashDiff.changed.length}, ` +
`added=${hashDiff.added.length}, ` +
`deleted=${hashDiff.deleted.length} ` +
`(skipping wipe + ${
allFilePaths.length - hashDiff.toWrite.length
} unchanged file rows preserved)`,
);
// Set the dirty flag BEFORE any destructive DB mutation. Cleared on
// success at the meta-save step.
await saveMeta(storagePath, {
...existingMeta!,
incrementalInProgress: {
startedAt: Date.now(),
toWriteCount: hashDiff.toWrite.length,
},
});
} else {
// Full rebuild path: wipe DB files first.
await closeLbug();
const lbugFiles = [lbugPath, `${lbugPath}.wal`, `${lbugPath}.lock`];
for (const f of lbugFiles) {
try {
await fs.rm(f, { recursive: true, force: true });
} catch {
/* swallow */
}
}
}
@ -296,11 +441,145 @@ export async function runFullAnalysis(
// must be released to avoid blocking subsequent invocations.
let lbugMsgCount = 0;
await loadGraphToLbug(pipelineResult.graph, pipelineResult.repoPath, storagePath, (msg) => {
lbugMsgCount++;
const pct = Math.min(84, 60 + Math.round((lbugMsgCount / (lbugMsgCount + 10)) * 24));
progress('lbug', pct, msg);
});
if (isIncremental && hashDiff) {
// ── Incremental DB writeback ───────────────────────────────────
// 0. Expand the writable set with transitive importers of
// changed/deleted files (bounded BFS).
//
// Reason (Bugbot/Claude review on PR #1479): when a barrel /
// re-export file C changes, cross-file resolution may update
// CALLS edges between two unchanged files A and B (A imports
// from C, C re-exports something from B). Those refined edges
// live in `ctx.graph` but would be excluded from the subgraph
// if neither endpoint is in the changed set. To catch this,
// files that imported (directly OR transitively, through
// other unchanged intermediaries) any changed file get pulled
// into the writable set so their rows are deleted + rewritten
// against the refined edges.
//
// BFS bound: MAX_IMPORTER_BFS_DEPTH. Practically sized to
// catch nested barrel chains (e.g. `index.ts → submodule/index.ts
// → submodule/impl.ts`) without ballooning into a near-full-
// rebuild on monorepos with deep re-export pyramids. Beyond
// this depth, the "incremental ≡ full-rebuild" invariant is
// self-acknowledged as best-effort; `--force` remains the
// escape hatch documented in GUARDRAILS.md.
//
// `queryImporters` reads `IMPORTS` from the pre-pipeline DB
// state, so the result is "files that USED TO import the
// target" — exactly the set whose previously-stored edges may
// no longer match what cross-file resolution produces this run.
const MAX_IMPORTER_BFS_DEPTH = 4;
const writableFiles = new Set<string>(hashDiff.toWrite);
const directlyChangedCount = writableFiles.size;
// Shadow-seed: for ADDED files, queryImporters returns 0 (the new
// file has no IMPORTS rows in the pre-pipeline DB yet). But pre-
// existing unchanged files may have IMPORTS edges whose module-
// resolution claim the newcomer can steal under standard JS/TS
// resolution (Bugbot review on PR #1479). For each added file we
// derive the shadow candidates and, if the candidate was a known
// file in the prior meta, seed it into the BFS frontier so its
// importers — surfaced via queryImporters — get their CALLS edges
// re-resolved against the new file. See shadow-candidates.ts for
// the full pattern catalogue.
const priorFileSet = new Set<string>(
existingMeta?.fileHashes ? Object.keys(existingMeta.fileHashes) : [],
);
const shadowSeed: string[] = [];
for (const added of hashDiff.added) {
for (const cand of shadowCandidatesFor(added)) {
if (priorFileSet.has(cand) && !writableFiles.has(cand)) {
shadowSeed.push(cand);
}
}
}
{
let frontier: string[] = [...hashDiff.toWrite, ...hashDiff.deleted, ...shadowSeed];
for (let depth = 0; depth < MAX_IMPORTER_BFS_DEPTH && frontier.length > 0; depth++) {
const nextFrontier: string[] = [];
for (const f of frontier) {
try {
const importers = await queryImporters(f);
for (const i of importers) {
if (!writableFiles.has(i)) {
writableFiles.add(i);
nextFrontier.push(i);
}
}
} catch {
/* per-file importer query failure skip; correctness degrades on
that branch, but DB stays writable. */
}
}
frontier = nextFrontier;
}
}
const importerExpansion = writableFiles.size - directlyChangedCount;
if (importerExpansion > 0) {
log(
`Incremental: +${importerExpansion} importer(s) added to writable set ` +
`(BFS depth ≤ ${MAX_IMPORTER_BFS_DEPTH}` +
(shadowSeed.length > 0 ? `, ${shadowSeed.length} shadow-seed(s)` : '') +
`)`,
);
}
// 1. Compute the EFFECTIVE write-set (Finding 1). Two layers,
// composed:
// (a) `writableFiles` — toWrite transitive importers of
// changed/deleted files (the bounded BFS above, reading
// IMPORTS from the pre-pipeline DB).
// (b) `computeEffectiveWriteSet` — walks the NEW graph's
// edges and pulls in any unchanged-side file that sits
// on a writable-boundary-crossing edge (catches refined
// cross-file CALLS edges that the pre-run DB couldn't
// predict, e.g. a barrel re-export shifting `foo` from
// B to D).
// The composed set is the input to BOTH deleteNodesForFile
// and extractChangedSubgraph — asymmetry between the two would
// leave stale rows or PK-conflict at COPY time.
const effectiveWriteSet = computeEffectiveWriteSet(pipelineResult.graph, writableFiles);
// Deduped: deleted entries may already appear via importer-BFS
// expansion (queryImporters can return a now-deleted path), which
// would otherwise call deleteNodesForFile twice for the same file
// (Bugbot LOW finding on PR #1479).
const filesToDelete = [...new Set([...effectiveWriteSet, ...hashDiff.deleted])];
for (let i = 0; i < filesToDelete.length; i++) {
const f = filesToDelete[i];
try {
await deleteNodesForFile(f);
} catch {
/* file may not have rows (e.g. an unparseable file) — fine */
}
if (i % 20 === 0) {
progress('lbug', 62, `Removing rows for changed files (${i}/${filesToDelete.length})...`);
}
}
// 2. Drop graph-wide nodes (Community, Process). They'll be re-inserted
// from the fresh pipeline output below. Required for the
// "Leiden runs on the FULL graph" correctness invariant.
await deleteAllCommunitiesAndProcesses();
// 3. Extract the changed subgraph from the FULL ctx.graph and write
// only that. Unchanged-file rows in the DB stay untouched. Pass
// the SAME effectiveWriteSet so the subgraph and the deletes
// cover identical files (asymmetry would silently corrupt).
const subgraph = extractChangedSubgraph(pipelineResult.graph, effectiveWriteSet);
await loadGraphToLbug(subgraph, pipelineResult.repoPath, storagePath, (msg) => {
lbugMsgCount++;
const pct = Math.min(84, 65 + Math.round((lbugMsgCount / (lbugMsgCount + 10)) * 19));
progress('lbug', pct, msg);
});
} else {
// ── Full rebuild ───────────────────────────────────────────────
await loadGraphToLbug(pipelineResult.graph, pipelineResult.repoPath, storagePath, (msg) => {
lbugMsgCount++;
const pct = Math.min(84, 60 + Math.round((lbugMsgCount / (lbugMsgCount + 10)) * 24));
progress('lbug', pct, msg);
});
}
// ── Phase 3: FTS (8590%) ─────────────────────────────────────────
progress('fts', 85, 'Creating search indexes...');
@ -308,6 +587,19 @@ export async function runFullAnalysis(
progress('fts', 90, 'Search indexes ready');
// ── Phase 3.5: Re-insert cached embeddings ────────────────────────
// Runs on BOTH the full-rebuild path and the incremental path:
// - Full rebuild: DB was wiped, every cached row needs to come back.
// - Incremental: changed-file rows were just deleted by
// deleteNodesForFile (which cascades to their
// embedding rows) — so their cached vectors need
// to come back too. Unchanged-file rows still
// exist; re-inserting their cached vectors would
// PK-conflict, but the per-batch try/catch below
// silently ignores those (matches the existing
// "some may fail if node was removed, that's
// fine" semantics). Bugbot review on PR #1479
// flagged that gating this on `!isIncremental`
// silently lost changed-file embeddings.
if (cachedEmbeddings.length > 0) {
const cachedDims = cachedEmbeddings[0].embedding.length;
const { EMBEDDING_DIMS } = await import('./lbug/schema.js');
@ -454,6 +746,12 @@ export async function runFullAnalysis(
const effectiveSemanticMode =
semanticMode ??
(runtimeCapabilities.semanticMode === 'vector-index' ? 'vector-index' : 'exact-scan');
// Convert the post-run file-hash map to the on-disk Record<string,string>
// shape consumed by RepoMeta.fileHashes.
const newFileHashesRecord: Record<string, string> = {};
for (const [k, v] of newFileHashes) newFileHashesRecord[k] = v;
const meta = {
repoPath,
lastCommit: currentCommit,
@ -483,8 +781,33 @@ export async function runFullAnalysis(
reason: runtimeCapabilities.reason,
},
},
// Incremental-indexing fields. Populated for git repos so the next
// analyze run can take the incremental DB-writeback path. Setting
// incrementalInProgress to undefined explicitly clears any prior
// dirty flag (full and incremental success paths converge here).
schemaVersion: hasGitDir(repoPath) ? INCREMENTAL_SCHEMA_VERSION : undefined,
fileHashes: hasGitDir(repoPath) ? newFileHashesRecord : undefined,
incrementalInProgress: undefined as { startedAt: number; toWriteCount: number } | undefined,
};
await saveMeta(storagePath, meta);
// Persist the incremental parse cache for the next run. Wraps in
// try/catch so a cache-write failure never breaks an otherwise
// successful indexing run. Prune stale chunk-hash entries first so
// the cache file size stays bounded across runs (chunks whose
// composition no longer matches anything in the current scan are
// dead weight; the parse phase populates `usedKeys` as it processes
// chunks).
try {
const pruned = pruneCache(parseCache, parseCache.usedKeys);
if (pruned > 0) {
log(`Parse cache: pruned ${pruned} stale chunk entries`);
}
await saveParseCache(storagePath, parseCache);
} catch (e) {
log(`Warning: could not save parse cache (${(e as Error).message}); continuing.`);
}
// Forward the --name alias and the registry-collision bypass bit.
// `allowDuplicateName` is its own concern — independent from the
// pipeline `force` above. The CLI maps it from
@ -527,7 +850,11 @@ export async function runFullAnalysis(
processes: pipelineResult.processResult?.stats.totalProcesses,
},
undefined,
{ skipAgentsMd: options.skipAgentsMd, noStats: options.noStats },
{
skipAgentsMd: options.skipAgentsMd,
skipSkills: options.skipSkills,
noStats: options.noStats,
},
);
} catch {
// Best-effort — don't fail the entire analysis for context file issues

View file

@ -0,0 +1,40 @@
import type Parser from 'tree-sitter';
/**
* tree-sitter 0.21.x's Node native binding crashes (SIGSEGV) on Windows when
* `parser.parse(string, …)` is handed a JS string longer than 32 767 chars.
* The crash happens inside the binding's V8 string-to-buffer conversion and
* cannot be intercepted from JavaScript. The callback (`Parser.Input`) overload
* pulls source in fixed-size chunks via repeated callback invocations and
* bypasses that conversion path entirely.
*
* Chunk size is comfortably below the boundary; any value < 32 767 works.
*/
const SAFE_PARSE_CHUNK_CHARS = 16 * 1024;
/**
* Files at or below this length skip the callback machinery and use the
* direct string overload the bug only manifests above the int16 boundary,
* so small inputs save the cost of N callback invocations per parse.
*/
const DIRECT_PARSE_LIMIT_CHARS = 16 * 1024;
/**
* Parse `sourceText` safely on every platform. See {@link SAFE_PARSE_CHUNK_CHARS}
* for the underlying tree-sitter binding bug this works around.
*/
export function parseSourceSafe(
parser: Parser,
sourceText: string,
oldTree?: Parser.Tree,
options?: Parser.Options,
): Parser.Tree {
if (sourceText.length <= DIRECT_PARSE_LIMIT_CHARS) {
return parser.parse(sourceText, oldTree, options);
}
const input: Parser.Input = (index) => {
if (index >= sourceText.length) return null;
return sourceText.slice(index, index + SAFE_PARSE_CHUNK_CHARS);
};
return parser.parse(input, oldTree, options);
}

View file

@ -11,6 +11,7 @@ import os from 'os';
import fs from 'fs/promises';
import { isIP } from 'net';
import { logger } from '../core/logger.js';
import { parseRepoNameFromUrl } from '../storage/git.js';
/** Root directory for all cloned repositories. Targets must resolve inside this. */
const CLONE_ROOT = path.resolve(path.join(os.homedir(), '.gitnexus', 'repos'));
@ -29,20 +30,17 @@ const REPO_NAME_PATTERN = /^[a-zA-Z0-9._-]+$/;
* clone root via path traversal.
*/
export function extractRepoName(url: string): string {
// Strip trailing slashes without a regex to avoid polynomial-ReDoS on
// pathological inputs like `https://x.com/y` + '/'.repeat(1e6). CodeQL's
// js/polynomial-redos flagged `/\/+$/` here.
let end = url.length;
while (end > 0 && url.charCodeAt(end - 1) === 47 /* '/' */) end--;
const cleaned = url.slice(0, end);
const lastSegment = cleaned.split(/[/:]/).pop() || '';
const stripped = lastSegment.endsWith('.git') ? lastSegment.slice(0, -4) : lastSegment;
if (!stripped || stripped === '.' || stripped === '..' || !REPO_NAME_PATTERN.test(stripped)) {
const name = parseRepoNameFromUrl(url);
if (
!name ||
name === '.' ||
name === '..' ||
name === 'unknown' ||
!REPO_NAME_PATTERN.test(name)
) {
throw new Error('Could not extract a valid repository name from URL');
}
return stripped;
return name;
}
/** Get the clone target directory for a repo name. */
@ -399,8 +397,8 @@ export async function cloneOrPull(
}
// Always validate the requested URL — the prior shape only ran this in
// the clone branch, leaving the pull branch as an SSRF / blocked-host
// bypass when an existing clone shared the basename of an attacker URL.
// the code path where the repo was cloned. Now it runs unconditionally,
// preventing SSRF / blocked-host bypasses even when targetDir already exists.
validateGitUrl(url);
const exists = await fs.access(path.join(safeTarget, '.git')).then(

View file

@ -0,0 +1,104 @@
/**
* Per-file content hashing for incremental DB writeback.
*
* On every analyze run we compute SHA-256 of every file's content and
* store the map in meta.json. The next run compares disk against the
* stored map and produces:
* - `changed` content differs (re-emit DB rows for this file)
* - `added` file is new on disk (insert DB rows)
* - `deleted` file was in last meta but no longer on disk (drop rows)
*
* The pipeline still parses every file (correctness invariant: cross-file
* resolution needs full data). What this enables is a SELECTIVE DB
* writeback: instead of wipe-and-reload of the whole graph (~50s of CSV
* COPY on a 25K-node repo), we only delete-and-rewrite rows for the
* changed/added/deleted set.
*
* See docs/superpowers/specs/2026-05-10-incremental-indexing-design.md
* (Option B revision).
*/
import { createHash } from 'crypto';
import fs from 'fs/promises';
import path from 'path';
/**
* Compute SHA-256 of a single file. Returns null when the file can't be
* read caller treats that as "no signature, assume changed".
*/
export const computeFileHash = async (absPath: string): Promise<string | null> => {
try {
const buf = await fs.readFile(absPath);
return createHash('sha256').update(buf).digest('hex');
} catch {
return null;
}
};
/**
* Compute SHA-256 hashes for many files in parallel batches. Files that
* fail to read are omitted from the result map.
*/
export const computeFileHashes = async (
repoPath: string,
relPaths: readonly string[],
): Promise<Map<string, string>> => {
const out = new Map<string, string>();
const BATCH = 100;
for (let i = 0; i < relPaths.length; i += BATCH) {
const batch = relPaths.slice(i, i + BATCH);
const results = await Promise.all(
batch.map(async (rel) => {
const h = await computeFileHash(path.join(repoPath, rel));
return h ? ([rel, h] as const) : null;
}),
);
for (const r of results) if (r) out.set(r[0], r[1]);
}
return out;
};
/** Result of comparing the current on-disk hashes against stored ones. */
export interface FileHashDiff {
/** Files whose content hash differs from stored. */
changed: string[];
/** Files in the current scan that weren't in the stored map. */
added: string[];
/** Files in the stored map that aren't in the current scan. */
deleted: string[];
/** All files whose DB rows must be replaced (changed added). */
toWrite: string[];
}
/**
* Diff a current hash map against a previously stored one.
*
* Sorted output so two runs produce identical diff arrays for the same
* changes useful for stable logging / equivalence checks.
*/
export const diffFileHashes = (
current: ReadonlyMap<string, string>,
stored: Readonly<Record<string, string>> | undefined,
): FileHashDiff => {
const storedMap = new Map<string, string>(stored ? Object.entries(stored) : []);
const changed: string[] = [];
const added: string[] = [];
for (const [p, h] of current) {
const prev = storedMap.get(p);
if (prev === undefined) added.push(p);
else if (prev !== h) changed.push(p);
}
const deleted: string[] = [];
for (const p of storedMap.keys()) {
if (!current.has(p)) deleted.push(p);
}
changed.sort();
added.sort();
deleted.sort();
return {
changed,
added,
deleted,
toWrite: [...changed, ...added].sort(),
};
};

Some files were not shown because too many files have changed in this diff Show more