diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1076dfba62..7dda320908 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -89,6 +89,8 @@ jobs: __tests__/same-file-match-confidence.test.ts __tests__/mcp-idle-release-order.test.ts __tests__/aosp-*.test.ts + __tests__/android-platform-language-coverage.test.ts + __tests__/sync-import-retry.test.ts __tests__/kotlin-object-literal.test.ts __tests__/extraction.test.ts __tests__/resolution.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3abffde1db..b5bd2ef1b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,9 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Indexes built on machines set to different languages now agree. When a TypeScript `this.field.method()` call finds its type declared in two equally near apps, a C++ class names its base through an alias two headers declare differently, or a React Native module's Swift class has a copy in two app targets, the tie is now broken the same way everywhere. Before, it followed the system's language settings, so two machines could link the same call to different code. Thanks @bompus. - `codegraph sync` no longer fails with "FOREIGN KEY constraint failed" in a project whose code lazy-loads a workspace package, like Supabase Studio's `dynamic(() => import('icons').then((mod) => mod.EdgeFunctions))` or vben's `() => import('@vben/layouts').then((m) => m.IFrameView)`. Before, every sync after an edit in such a project rolled back, so the index stopped following your changes until a full re-index. Thanks @bompus for the report and @inth3shadows. (#2565) - `codegraph status`, and the status your agent sees through `codegraph_status`, now report uncommitted edits, new files and deletions in a project that sits in a folder of its git repository, like a monorepo package or an `app/` folder beside a `server/` one. Before, they said the index was up to date while `codegraph sync` found the very same changes. A file put back with git after a sync now shows as pending there too, and a project in a folder that its repository's `.gitignore` leaves out is checked file by file instead of always reading as unchanged. Thanks @innoxxicide for the report and the first fix. (#1636) +- Android projects can opt their top-level vendor implementation source into indexing with `"include": ["vendor/"]` without broad include globs also admitting unrelated vendor dependencies, while nested dependencies and build output stay excluded and clean status checks avoid rereading unchanged vendor source. +- Java generic inheritance and public nested interface calls now retain their valid links, cross-package calls to package-private methods are rejected, and annotated AIDL return methods are recognized; re-index affected projects after upgrading. + - In VB.NET, every member of a `Structure` is now indexed, including its fields, properties, methods, constructors and nested enums. Before, only the first member was, so the rest could not be found and their callers looked empty. - In Delphi, a routine with an attribute on one of its parameters, like `const [ref] AValue: Integer` or DelphiMVCFramework's `const [MVCFromBody] Person: TPerson`, is now indexed with its calls, and so is every class member declared after it. Before, the routine was lost or its calls were credited to the file, the parameter showed up as a field of the class, and the members after it were missing. Thanks @stalexteam for the report. (#2545) - In Delphi, a `for … in` loop that declares its variable inline, like `for var lItem in AList do` or `for var lHandle: THandle in lHandles do`, now has its calls indexed, and a call on a typed loop variable reaches that type's method. Before, the whole loop was skipped, so a routine calling a method only from such a loop was missing from that method's callers. Thanks @stalexteam for the report. (#2546) diff --git a/README.md b/README.md index 6720fe072c..1ea67a22d2 100644 --- a/README.md +++ b/README.md @@ -710,7 +710,9 @@ only revives embedded git repos, not plain source): CodeGraph discovers those files off disk, overriding `.gitignore`, on index, sync, and watch. An explicit `exclude` still wins, and built-in skips -(`node_modules`, `dist`, `.git`) are never re-included. +(`node_modules`, `dist`, `.git`) are never re-included. For Android platform +trees, `"include": ["vendor/"]` explicitly includes project-root vendor +implementations while keeping nested dependency and build directories skipped. Sometimes a directory shouldn't leave the index — you still want to find things in it — it just shouldn't *outrank* your real code. A `scripts/` or diff --git a/__tests__/android-platform-language-coverage.test.ts b/__tests__/android-platform-language-coverage.test.ts new file mode 100644 index 0000000000..43cae57729 --- /dev/null +++ b/__tests__/android-platform-language-coverage.test.ts @@ -0,0 +1,320 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { CodeGraph } from '../src'; +import { scanDirectory, scanDirectoryAsync, buildScopeIgnore } from '../src/extraction'; +import { clearProjectConfigCache } from '../src/project-config'; +import { parseAidlDeclarations, findAidlImpl } from '../src/aosp/aidl'; +import { parseHalDeclarations } from '../src/aosp/hal'; +import { gateLanguageMatch, isVisibleAcrossFiles } from '../src/resolution/name-matcher'; +import type { ResolutionContext } from '../src/resolution'; +import type { UnresolvedRef } from '../src/resolution/types'; + +// Count source opens while keeping real filesystem and database operations. +vi.mock('fs', async importOriginal => { + const actual = await importOriginal(); + return { ...actual, openSync: vi.fn(actual.openSync) }; +}); + +describe('Android source coverage', () => { + let dir: string; + let cg: CodeGraph | undefined; + const write = (rel: string, source: string) => { + const file = path.join(dir, rel); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, source); + }; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-android-source-')); + clearProjectConfigCache(); + }); + afterEach(() => { + cg?.destroy(); + vi.restoreAllMocks(); + clearProjectConfigCache(); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('indexes explicitly included vendor implementations while keeping dependency dirs ignored', async () => { + write('vendor/oem/IVendor.aidl', 'package oem; interface IVendor { void run(); }'); + write('vendor/oem/VendorStub.java', 'package oem; public class VendorStub extends IVendor.Stub { public void run() {} }'); + write('vendor/oem/node_modules/noise.js', 'function noise() {}'); + write('vendor/oem/tool/vendor/dependency/Noise.java', 'public class Noise {}'); + write('.gitignore', 'vendor/\n'); + expect(scanDirectory(dir)).not.toContain('vendor/oem/VendorStub.java'); + write('codegraph.json', JSON.stringify({ include: ['vendor/'] })); + execFileSync('git', ['init', '-q'], { cwd: dir }); + execFileSync('git', ['add', '-A'], { cwd: dir }); + execFileSync('git', ['-c', 'user.email=a@b.c', '-c', 'user.name=t', 'commit', '-qm', 'base'], { cwd: dir }); + clearProjectConfigCache(); + expect(scanDirectory(dir)).toContain('vendor/oem/VendorStub.java'); + expect(scanDirectory(dir)).not.toContain('vendor/oem/node_modules/noise.js'); + expect(scanDirectory(dir)).not.toContain('vendor/oem/tool/vendor/dependency/Noise.java'); + expect(buildScopeIgnore(dir).ignores('vendor/oem/VendorStub.java')).toBe(false); + cg = await CodeGraph.init(dir, { index: true }); + expect(cg.getNodesByName('VendorStub').length).toBeGreaterThan(0); + expect(findAidlImpl(cg, dir, 'IVendor').status).toBe('found'); + write('vendor/oem/VendorStub.java', 'package oem; public class VendorStub extends IVendor.Stub { public void run() {} public void added() {} }'); + expect(cg.getChangedFiles().modified).toContain('vendor/oem/VendorStub.java'); + fs.rmSync(path.join(dir, 'vendor/oem/VendorStub.java')); + expect(cg.getChangedFiles().removed).toContain('vendor/oem/VendorStub.java'); + }); + + it.each(['vendor/', 'vendor/oem/', 'vendor/**/*.java'])( + 'keeps nested dependency trees excluded with include %s', (include) => { + const source = 'public class VendorSource {}'; + const allowed = 'vendor/oem/VendorSource.java'; + const dependencies = [ + 'vendor/oem/tool/vendor/dependency/Noise.java', + 'vendor/vendor/Noise.java', + 'packages/tool/vendor/Noise.java', + 'vendor/oem/node_modules/Noise.java', + 'vendor/oem/dist/Noise.java', + ]; + write(allowed, source); + for (const file of dependencies) write(file, source); + write('vendor/oem/excluded/Noise.java', source); + write('codegraph.json', JSON.stringify({ include: [include], exclude: ['vendor/oem/excluded/'] })); + clearProjectConfigCache(); + const files = scanDirectory(dir); + const scope = buildScopeIgnore(dir); + expect(files).toContain(allowed); + expect(scope.ignores(allowed)).toBe(false); + for (const file of [...dependencies, 'vendor/oem/excluded/Noise.java']) { + expect(files).not.toContain(file); + expect(scope.ignores(file)).toBe(true); + } + }, + ); + + it.each(['filesystem', 'git'])('keeps vendor opt-in and src/build output filtering together (%s)', async (mode) => { + const sources = [ + 'vendor/oem/src/build/VendorFeature.ts', + 'vendor/oem/src/main/java/oem/build/Device.java', + ]; + const excluded = [ + 'vendor/oem/src/build/bundle.js', + 'vendor/oem/src/build/types.d.ts', + 'vendor/oem/src/build/node_modules/pkg/Noise.ts', + 'vendor/oem/src/build/vendor/pkg/Noise.ts', + 'vendor/oem/src/build/dist/Noise.ts', + ]; + write(sources[0]!, 'export function VendorFeature() { return 1; }'); + write(sources[1]!, 'package oem.build; public class Device {}'); + for (const file of excluded) write(file, 'export const noise = 1;'); + write('.gitignore', 'vendor/\n'); + write('codegraph.json', JSON.stringify({ include: ['vendor/'] })); + if (mode === 'git') { + execFileSync('git', ['init', '-q'], { cwd: dir }); + execFileSync('git', ['add', '-A'], { cwd: dir }); + execFileSync('git', ['-c', 'user.email=a@b.c', '-c', 'user.name=t', 'commit', '-qm', 'base'], { cwd: dir }); + } + clearProjectConfigCache(); + const files = scanDirectory(dir); + const scope = buildScopeIgnore(dir); + for (const file of sources) { + expect(files).toContain(file); + expect(scope.ignores(file)).toBe(false); + } + for (const file of excluded) { + expect(files).not.toContain(file); + expect(scope.ignores(file)).toBe(true); + } + cg = await CodeGraph.init(dir, { index: true }); + expect(cg.getNodesByName('VendorFeature')).toHaveLength(1); + expect(cg.getNodesByName('Device')).toHaveLength(1); + expect(cg.getNodesByName('noise')).toHaveLength(0); + }); + + it.each(['filesystem', 'git'])('admits vendor files only through their own vendor-rooted include (%s)', async (mode) => { + write('src/Main.go', 'package main; func Main() {}'); + write('vendor/oem/Thing.java', 'package oem; public class Thing {}'); + const excluded = ['vendor/dep/X.go', 'vendor/oem/Unexpected.go', 'vendor/oem/excluded/Hidden.java']; + for (const file of excluded) write(file, 'package noise;'); + write('.gitignore', 'vendor/\n'); + if (mode === 'git') { + execFileSync('git', ['init', '-q'], { cwd: dir }); + execFileSync('git', ['add', '-A'], { cwd: dir }); + // Tracked dependencies must respect the same include policy as ignored files. + execFileSync('git', ['add', '-f', 'vendor'], { cwd: dir }); + execFileSync('git', ['-c', 'user.email=a@b.c', '-c', 'user.name=t', 'commit', '-qm', 'base'], { cwd: dir }); + } + for (const include of [ + ['**/*.go'], + ['vendor/oem/**/*.java', '**/*.go', '!vendor/oem/excluded/**'], + ['**/*.go', '/vendor/oem/**/*.java', '!vendor/oem/excluded/**'], + ]) { + write('codegraph.json', JSON.stringify({ include })); + clearProjectConfigCache(); + const scope = buildScopeIgnore(dir); + const vendorOptIn = include.length > 1; + expect(scope.ignores('vendor/')).toBe(!vendorOptIn); + expect(scope.ignores('vendor/dep/')).toBe(true); + expect(scope.ignores('vendor/oem/Thing.java')).toBe(!vendorOptIn); + for (const files of [scanDirectory(dir), await scanDirectoryAsync(dir)]) { + expect(files).toContain('src/Main.go'); + expect(files.includes('vendor/oem/Thing.java')).toBe(vendorOptIn); + for (const file of excluded) { + expect(files).not.toContain(file); + expect(scope.ignores(file)).toBe(true); + } + } + } + }); + + it('does not read unchanged included source during git status checks', async () => { + const file = 'vendor/oem/Device.java'; + const source = 'package oem; public class Device { public void run() {} }'; + write(file, source); + write('src/Tracked.java', 'public class Tracked { void run() {} }'); + write('.gitignore', 'vendor/\n'); + write('codegraph.json', JSON.stringify({ include: ['vendor/'] })); + execFileSync('git', ['init', '-q'], { cwd: dir }); + execFileSync('git', ['add', '-A'], { cwd: dir }); + execFileSync('git', ['-c', 'user.email=a@b.c', '-c', 'user.name=t', 'commit', '-qm', 'base'], { cwd: dir }); + clearProjectConfigCache(); + cg = await CodeGraph.init(dir, { index: true }); + const opened = vi.mocked(fs.openSync); + const sourceOpens = () => opened.mock.calls.filter(([name]) => String(name) === path.join(dir, file)); + opened.mockClear(); + expect(cg.getChangedFiles()).toEqual({ added: [], modified: [], removed: [] }); + expect(sourceOpens()).toHaveLength(0); + + // A metadata-only change still hashes once to confirm identical content. + const stat = fs.statSync(path.join(dir, file)); + fs.utimesSync(path.join(dir, file), stat.atime, new Date(stat.mtimeMs + 2000)); + opened.mockClear(); + expect(cg.getChangedFiles().modified).toEqual([]); + expect(sourceOpens()).toHaveLength(1); + const sameSizeChange = source.replace('run()', 'end()'); + expect(sameSizeChange).not.toBe(source); + expect(Buffer.byteLength(sameSizeChange)).toBe(stat.size); + write(file, sameSizeChange); // Same size, new mtime. + fs.utimesSync(path.join(dir, file), stat.atime, new Date(stat.mtimeMs + 4000)); + expect(cg.getChangedFiles().modified).toEqual([file]); + await cg.sync(); + // Git evidence must still win even when content changes preserve metadata. + const tracked = path.join(dir, 'src/Tracked.java'); + const trackedStat = fs.statSync(tracked); + write('src/Tracked.java', 'public class Tracked { void end() {} }'); + execFileSync('git', ['add', 'src/Tracked.java'], { cwd: dir }); + fs.utimesSync(tracked, trackedStat.atime, trackedStat.mtime); + expect(cg.getChangedFiles().modified).toEqual(['src/Tracked.java']); + write('vendor/oem/New.java', 'package oem; public class New {}'); + expect(cg.getChangedFiles().added).toEqual(['vendor/oem/New.java']); + fs.rmSync(path.join(dir, file)); + expect(cg.getChangedFiles().removed).toEqual([file]); + }); + + it('allows cross-package calls through implicitly public nested Java interfaces', async () => { + write('p/Outer.java', `package p; +public interface Outer { + interface Inner { + static void api() {} + interface Deep { static void deepApi() {} } + private static void hidden() {} + } +}`); + write('p/Closed.java', 'package p; interface Closed { interface Inner { static void closedApi() {} } }'); + write('q/Caller.java', `package q; +import p.Outer; +public class Caller { + void run() { + Outer.Inner.api(); + Outer.Inner.Deep.deepApi(); + } +}`); + cg = await CodeGraph.init(dir, { index: true }); + const nodes = [...cg.getNodesByKind('interface'), ...cg.getNodesByKind('method'), ...cg.getNodesByKind('namespace')]; + const context = { getNodesInFile: (file: string) => nodes.filter((node) => node.filePath === file) } as ResolutionContext; + const ref = { filePath: 'q/Caller.java', language: 'java' } as UnresolvedRef; + for (const name of ['api', 'deepApi']) { + const method = cg.getNodesByName(name).find((n) => n.kind === 'method')!; + expect(isVisibleAcrossFiles(method, ref, context)).toBe(true); + expect(cg.getCallers(method.id).map((c) => c.node.filePath)).toContain('q/Caller.java'); + } + for (const name of ['hidden', 'closedApi']) { + const method = cg.getNodesByName(name).find((n) => n.kind === 'method')!; + expect(isVisibleAcrossFiles(method, ref, context)).toBe(false); + } + }); + + it('extracts annotated AIDL return methods from public AOSP syntax', () => { + write('hardware/interfaces/audio/IModule.aidl', + 'package android.hardware.audio.core;\ninterface IModule {\n @nullable IBluetoothLe getBluetoothLe();\n void reset();\n}'); + expect(parseAidlDeclarations(dir, 'IModule')[0]?.methods).toEqual(['getBluetoothLe', 'reset']); + expect(parseHalDeclarations(dir, 'IModule', '.aidl')[0]?.methods).toEqual(['getBluetoothLe', 'reset']); + }); + + it('resolves generic Java supertypes without losing qualified nested names', async () => { + write('Base.java', 'package p; public class Base {}'); + write('Face.java', 'package p; public interface Face {}'); + write('Outer.java', 'package p; public class Outer { public class Inner {} }'); + write('Derived.java', 'package p; public class Derived extends Base implements Face {}'); + write('Nested.java', 'package p; public class Nested extends Outer.Inner { Nested(Outer o) { o.super(); } }'); + cg = await CodeGraph.init(dir, { index: true }); + const targets = (name: string) => { + const id = cg!.getNodesByName(name).find((n) => n.kind === 'class')!.id; + return cg!.getOutgoingEdges(id).filter((e) => e.kind === 'extends' || e.kind === 'implements') + .map((e) => cg!.getNode(e.target)?.name); + }; + expect(targets('Derived')).toEqual(expect.arrayContaining(['Base', 'Face'])); + expect(targets('Nested')).toContain('Inner'); + }); + + it('indexes methods declared by Java enum constant class bodies', async () => { + write('Mode.java', 'package p; enum Mode { ON { int level() { return 1; } }, OFF { int level() { return 0; } }; abstract int level(); }'); + cg = await CodeGraph.init(dir, { index: true }); + const methods = cg.getNodesByName('level').filter((n) => n.kind === 'method').map((n) => n.qualifiedName); + expect(methods).toEqual(expect.arrayContaining(['p::Mode::ON::level', 'p::Mode::OFF::level', 'p::Mode::level'])); + }); + + it('allows package-private static calls in the same Java package only', async () => { + write('p/Utility.java', 'package p; public class Utility { static void hidden() {} public static void open() {} }'); + write('p/Contract.java', 'package p; public interface Contract { static void api() {} }'); + write('other/Same.java', 'package p; public class Same { void run() { Utility.hidden(); } }'); + write('q/Other.java', 'package q; import p.Utility; import p.Contract; public class Other { void run() { Utility.hidden(); Utility.open(); Contract.api(); } }'); + cg = await CodeGraph.init(dir, { index: true }); + const hidden = cg.getNodesByName('hidden').find((n) => n.kind === 'method')!; + const open = cg.getNodesByName('open').find((n) => n.kind === 'method')!; + const api = cg.getNodesByName('api').find((n) => n.kind === 'method')!; + const callers = (id: string) => cg!.getCallers(id).map((c) => c.node.filePath); + expect(callers(hidden.id)).toContain('other/Same.java'); + expect(callers(hidden.id)).not.toContain('q/Other.java'); + expect(callers(open.id)).toContain('q/Other.java'); + const nodes = [...cg.getNodesByKind('method'), ...cg.getNodesByKind('class'), ...cg.getNodesByKind('interface'), ...cg.getNodesByKind('namespace')]; + const context = { + getNodesInFile: (file: string) => nodes.filter((node) => node.filePath === file), + getNodeById: (id: string) => nodes.find((node) => node.id === id) ?? null, + } as ResolutionContext; + const ref = { filePath: 'q/Other.java', language: 'java', referenceName: 'hidden', referenceKind: 'calls' } as UnresolvedRef; + expect(gateLanguageMatch({ original: ref, targetNodeId: hidden.id, confidence: 1, resolvedBy: 'import' }, ref, context)).toBeNull(); + expect(isVisibleAcrossFiles(api, { filePath: 'q/Other.java', language: 'java' } as UnresolvedRef, context)).toBe(true); + }); + + it('keeps private Java overloads inaccessible from another file', async () => { + write('p/Utility.java', `package p; +public class Utility { + public static void pick() {} + private static void pick(String value) {} + public static void inside() { pick("ok"); } +}`); + write('q/Caller.java', `package q; +import p.Utility; +public class Caller { + void invalid() { Utility.pick("hidden"); } + void valid() { Utility.pick(); } +}`); + cg = await CodeGraph.init(dir, { index: true }); + const methods = cg.getNodesInFile('p/Utility.java').filter((n) => n.name === 'pick'); + const hidden = methods.find((n) => n.visibility === 'private')!; + expect(hidden).toBeDefined(); + expect(cg.getCallers(hidden.id).map((c) => c.node.filePath)).toContain('p/Utility.java'); + expect(cg.getCallers(hidden.id).map((c) => c.node.filePath)).not.toContain('q/Caller.java'); + const publicOverload = methods.find((n) => n.visibility === 'public')!; + expect(cg.getCallers(publicOverload.id).map((c) => c.node.name)).toContain('valid'); + }); +}); diff --git a/__tests__/aosp-aidl.test.ts b/__tests__/aosp-aidl.test.ts index 766773a508..c9b782ea82 100644 --- a/__tests__/aosp-aidl.test.ts +++ b/__tests__/aosp-aidl.test.ts @@ -408,18 +408,8 @@ describe('AOSP extension: findAidlImpl', () => { 'package com.oem.vendor;\ninterface IVendorOnly {\n void doVendorThing();\n}\n' ); write( - // A Kotlin/Java implementer under vendor/ is deliberately NOT part - // of this fixture: CodeGraph's own core indexer (directory.ts, - // extraction/index.ts) ignores `vendor/` by default for every - // language, independent of this aosp extension — a Kotlin/Java - // implementer placed there would never produce an `unresolved_refs` - // hit no matter what this file discovers, and fixing that is a core - // indexing-policy change outside this fork's scope, not an aosp - // extension bug. This test only verifies the piece this extension - // DOES own: `findAidlFiles` walks its OWN file-system scan of - // `.aidl` files (which core never indexes for any repo) and must - // not silently drop a real vendor-tree declaration the way the - // previous `IGNORED_DIR_NAMES` did. + // This fixture isolates declaration discovery. A separate Android + // coverage test opts vendor/ implementation source into the core index. 'src/UnrelatedNonVendor.kt', 'package com.example.other\n\nclass UnrelatedNonVendor\n' ); diff --git a/codegraph-kernel/src/java.rs b/codegraph-kernel/src/java.rs index 3b5abca452..f2c4605432 100644 --- a/codegraph-kernel/src/java.rs +++ b/codegraph-kernel/src/java.rs @@ -678,6 +678,7 @@ impl<'t> Walker<'t> { let name = self.extract_name(node); let extra = Extra { docstring: preceding_docstring(node, self.src), + visibility: self.visibility_of(node), ..Extra::default() }; let Some(row) = self.create_node("interface", &name, node, extra) else { return }; @@ -1058,7 +1059,7 @@ impl<'t> Walker<'t> { None => child.named_child(0).into_iter().collect(), }; for target in targets { - let name = self.supertype_name(target); + let name = strip_java_type_args(&self.supertype_name(target)); self.push_ref_at(class_row, &name, extends_kind, target); } } @@ -1071,7 +1072,7 @@ impl<'t> Walker<'t> { None => (0..child.named_child_count()).filter_map(|j| child.named_child(j)).collect(), }; for iface in targets { - let name = self.supertype_name(iface); + let name = strip_java_type_args(&self.supertype_name(iface)); self.push_ref_at(class_row, &name, implements_kind, iface); } } @@ -1707,6 +1708,16 @@ fn find_anonymous_class_body(node: Node) -> Option { None } +/// Keep every qualified segment of a Java supertype while dropping type arguments. +fn strip_java_type_args(raw: &str) -> String { + let mut depth = 0; + raw.chars().filter(|&ch| { + if ch == '<' { depth += 1; return false; } + if ch == '>' { depth -= 1; return false; } + depth == 0 + }).collect::().trim().to_string() +} + /// The `new ns.Foo()` name normalization shared by instantiation / /// anonymous-class / decorator extraction: strip `<...` from the first `<` /// (index > 0), keep the segment after the last `.`/`::`, strip ONE leading diff --git a/src/aosp/aidl.ts b/src/aosp/aidl.ts index 3f9c148dfb..eb64faebe8 100644 --- a/src/aosp/aidl.ts +++ b/src/aosp/aidl.ts @@ -122,7 +122,7 @@ export interface FindAidlImplResult { // itself ASCII, so the boundary check still does its job. const AIDL_INTERFACE_RE = /\binterface\s+([\p{L}\p{N}_$]+)\s*\{/gu; // AIDL method declarations: ` name();`, optional `oneway`. -const AIDL_METHOD_RE = /^\s*(?:oneway\s+)?[\w<>[\],.\s]+?\s+([\p{L}\p{N}_$]+)\s*\([^;{]*\)\s*;/gmu; +const AIDL_METHOD_RE = /^\s*(?:oneway\s+)?(?:@[\w.]+(?:\([^;]*?\))?\s+)*[\w<>[\],.\s]+?\s+([\p{L}\p{N}_$]+)\s*\([^;{]*\)\s*;/gmu; const AIDL_PACKAGE_RE = /^\s*package\s+([\p{L}\p{N}_.]+)\s*;/mu; // NOT a general-purpose "vendored third-party code" ignore list — this is an // AOSP-specific walker, and `vendor/` in an AOSP tree is a first-class, diff --git a/src/aosp/hal.ts b/src/aosp/hal.ts index c8fdc357b5..d9768f8ee5 100644 --- a/src/aosp/hal.ts +++ b/src/aosp/hal.ts @@ -42,7 +42,7 @@ import type { AospCandidate, FindAidlImplStatus, AidlDeclaration } from './aidl' // failed (the character right after the interface name is `e`, not `{`) — // the entire declaration went unmatched, not just mis-parsed. const HAL_INTERFACE_RE = /\binterface\s+([\p{L}\p{N}_$]+)(?:\s+extends\s+[\p{L}\p{N}_$.@:]+)?\s*\{/gu; -const HAL_METHOD_RE = /^\s*(?:oneway\s+)?[\w<>[\],.\s]+?\s+([\p{L}\p{N}_$]+)\s*\([^;{]*\)\s*;/gmu; +const HAL_METHOD_RE = /^\s*(?:oneway\s+)?(?:@[\w.]+(?:\([^;]*?\))?\s+)*[\w<>[\],.\s]+?\s+([\p{L}\p{N}_$]+)\s*\([^;{]*\)\s*;/gmu; // HIDL's package clause carries a version suffix (`package android.hardware.foo@1.0;`). // The bare package (with `@version` stripped) still needs to be captured for the // same-package-membership check, but the version itself is captured separately — diff --git a/src/extraction/extraction-version.ts b/src/extraction/extraction-version.ts index e41b5f6149..00e67c089b 100644 --- a/src/extraction/extraction-version.ts +++ b/src/extraction/extraction-version.ts @@ -21,4 +21,4 @@ * turns the re-index hint into noise — keep it honest (see CLAUDE.md, "Honesty * in the product is load-bearing"). */ -export const EXTRACTION_VERSION = 28; +export const EXTRACTION_VERSION = 29; diff --git a/src/extraction/index.ts b/src/extraction/index.ts index 20349209d2..e0f96dd56b 100644 --- a/src/extraction/index.ts +++ b/src/extraction/index.ts @@ -610,8 +610,10 @@ export function buildDefaultIgnore(rootDir: string): Ignore { * parent repo's own ignore rules must NOT apply — inside embedded child repos, * whose gitignore semantics their own `git ls-files` already enforced (#514). */ -function defaultsOnlyIgnore(): Ignore { - return excludeBuildOutputUnderSrc(ignore().add(DEFAULT_IGNORE_PATTERNS), null); +function defaultsOnlyIgnore(allowVendor = false): Ignore { + // Only the project-root Android vendor tree is includable; nested vendor + // directories remain dependency trees at every depth. + return excludeBuildOutputUnderSrc(ignore().add(DEFAULT_IGNORE_PATTERNS.map((p) => allowVendor && p === 'vendor/' ? '/*/**/vendor/' : p)), null); } /** @@ -700,6 +702,29 @@ function includeStaticRoots(patterns: string[]): string[] { return all.filter((r) => !all.some((other) => other !== r && r.startsWith(other))); } +interface VendorInclude { + matcher: Ignore; + roots: string[]; +} + +/** Broad includes do not opt dependency trees in; root vendor source needs its own pattern. */ +function buildVendorInclude(patterns: string[]): VendorInclude | null { + const rooted = patterns.filter((p) => /^\/*vendor(?:\/|$)/i.test(p)); + if (rooted.length === 0) return null; + return { + matcher: ignore().add(patterns.filter((p) => p.startsWith('!') || rooted.includes(p))), + roots: includeStaticRoots(rooted), + }; +} + +function permitsVendorInclude(rel: string, vendor: VendorInclude | null): boolean { + if (!/^vendor(?:\/|$)/i.test(rel)) return true; + if (!vendor) return false; + return rel.endsWith('/') + ? vendor.roots.some((root) => root.startsWith(rel) || rel.startsWith(root)) + : vendor.matcher.ignores(rel); +} + /** * Actively discover the source files an `include` whitelist forces in. `git * ls-files` never lists gitignored files, so a filtered filesystem walk of just @@ -716,16 +741,17 @@ function collectIncludedFiles( rootDir: string, include: Ignore, exclude: Ignore | null, - roots: string[], + patterns: string[], overrides: Record, ): Set { const out = new Set(); - const defaults = defaultsOnlyIgnore(); + const defaults = defaultsOnlyIgnore(true); // Explicit `include` may select AOSP vendor source. + const vendor = buildVendorInclude(patterns); const visited = new Set(); const consider = (abs: string, rel: string, isDir: boolean): void => { if (isDir) { - if (defaults.ignores(rel + '/')) return; // never node_modules/dist/… via include + if (defaults.ignores(rel + '/') || !permitsVendorInclude(rel + '/', vendor)) return; // An explicit `exclude` always wins over `include`; prune the whole subtree // here so a large excluded dir (a committed frontend's own vendored deps, // build output, …) is never walked — the per-file guard below still catches @@ -733,7 +759,7 @@ function collectIncludedFiles( if (exclude && exclude.ignores(rel + '/')) return; walk(abs); } else { - if (defaults.ignores(rel)) return; + if (defaults.ignores(rel) || !permitsVendorInclude(rel, vendor)) return; if (!include.ignores(rel)) return; if (exclude && exclude.ignores(rel)) return; if (!isSourceFile(rel, overrides, rootDir)) return; @@ -775,7 +801,7 @@ function collectIncludedFiles( } } - for (const root of roots) { + for (const root of includeStaticRoots(patterns)) { walk(root === '' ? rootDir : path.join(rootDir, root)); } return out; @@ -790,8 +816,7 @@ function collectIncludedFiles( function collectIncludedFilesForRoot(rootDir: string): Set { const include = loadIncludeMatcher(rootDir); if (!include) return new Set(); - const roots = includeStaticRoots(loadIncludePatterns(rootDir)); - return collectIncludedFiles(rootDir, include, loadExcludeMatcher(rootDir), roots, loadExtensionOverrides(rootDir)); + return collectIncludedFiles(rootDir, include, loadExcludeMatcher(rootDir), loadIncludePatterns(rootDir), loadExtensionOverrides(rootDir)); } /** @@ -971,6 +996,7 @@ export function preloadLanguagesForFiles( export class ScopeIgnore { private embedded: Array<{ root: string; matcher: Ignore }>; private defaults: Ignore = defaultsOnlyIgnore(); + private includableDefaults: Ignore = defaultsOnlyIgnore(true); constructor( private rootMatcher: Ignore, embedded: Array<{ root: string; matcher: Ignore }>, @@ -1000,6 +1026,7 @@ export class ScopeIgnore { * folder named `[draft]` or `a[b` read as a glob (or did not compile). */ private gitIgnoredDirs: ReadonlySet = new Set(), + private vendorInclude: VendorInclude | null = null, ) { // Longest root first so paths in nested embedded repos hit the innermost matcher. this.embedded = [...embedded].sort((a, b) => b.root.length - a.root.length); @@ -1022,7 +1049,7 @@ export class ScopeIgnore { // User `include`: force first-party source in despite `.gitignore`. Never // resurfaces a built-in default-ignored dir (node_modules/dist/…), so an // include pattern can't accidentally pull in dependency/build trees. - if (this.include && !this.defaults.ignores(rel)) { + if (this.include && !this.includableDefaults.ignores(rel) && permitsVendorInclude(rel, this.vendorInclude)) { if (rel.endsWith('/')) { // A directory on (or leading to) an included subtree must stay walkable // so the watcher/walker descends to reach the forced-in files. @@ -1068,6 +1095,7 @@ export function buildScopeIgnore(rootDir: string, embeddedRoots?: Iterable n.id === result.targetNodeId); if (target && crossesCodeBoundary(ref.language, target.language) && !hasBridgeEvidence(target, ref, context)) return null; + if (target?.language === 'java' && ref.language === 'java' && + !isVisibleAcrossFiles(target, ref, context)) return null; return result; } @@ -3585,6 +3591,28 @@ export function isVisibleAcrossFiles(candidate: Node, ref: UnresolvedRef, contex if (lang === 'dart') { return candidate.visibility !== 'private' || (ref.language === 'dart' && dartPrivateVisible(ref.filePath, candidate.filePath, context)); } + // Package namespaces are import targets, not access-controlled members. + if (lang === 'java' && candidate.kind !== 'namespace') { + if (candidate.visibility === 'private') return false; + if (candidate.visibility == null) { + const fileNodes = context.getNodesInFile(candidate.filePath); + const ownerOf = (node: Node): Node | undefined => fileNodes.find((parent) => + parent.qualifiedName === node.qualifiedName.split('::').slice(0, -1).join('::')); + // A member interface is implicitly public only through an accessible + // interface owner; package-private roots and private members stay closed. + const isPublic = (node: Node): boolean => { + if (node.visibility != null) return node.visibility === 'public'; + const parent = ownerOf(node); + return parent?.kind === 'interface' && isPublic(parent); + }; + const owner = ownerOf(candidate); + if (owner && isPublic(owner) && + (owner.kind === 'interface' || (owner.kind === 'enum' && candidate.kind === 'enum_member'))) return true; + const packageName = (nodes: Node[]): string => nodes.find((node) => node.kind === 'namespace')?.name ?? ''; + return packageName(fileNodes) === packageName(context.getNodesInFile(ref.filePath)); + } + return true; + } if (PRIVATE_IS_FILE_LOCAL.has(lang)) { // Only Kotlin's private type keeps its members in its file: a Scala // `private[pkg] class` (or one with a private constructor) is the package's,