diff --git a/gitnexus/scripts/cross-platform-tests.ts b/gitnexus/scripts/cross-platform-tests.ts index c5c589a5d..febc8cf99 100644 --- a/gitnexus/scripts/cross-platform-tests.ts +++ b/gitnexus/scripts/cross-platform-tests.ts @@ -161,6 +161,8 @@ const PLATFORM_LOGIC = [ // N-API addon which has known platform-specific behavior (Windows // file-lock lag after close, macOS N-API destructor segfaults) const LBUG_NATIVE = [ + 'test/integration/impact-private-type-file-seed.test.ts', + 'test/integration/impact-private-type-source.test.ts', // Inline-handler findings must survive native persistence and cold explain // readers on each OS; passing the Ubuntu-only taint suite is insufficient. 'test/integration/taint-explain.test.ts', diff --git a/gitnexus/src/core/ingestion/language-provider.ts b/gitnexus/src/core/ingestion/language-provider.ts index b8665f1cd..8e6d915b5 100644 --- a/gitnexus/src/core/ingestion/language-provider.ts +++ b/gitnexus/src/core/ingestion/language-provider.ts @@ -354,6 +354,33 @@ interface LanguageProviderConfig { /** Export detection: is this AST node a public/exported symbol? */ readonly exportChecker: ExportChecker; + /** + * Does a stored `false` from `exportChecker` prove file privacy? A function + * can confirm this against the indexed file content; missing content must + * retain the fallback. The graph flag is NOT SymbolDefinition's tri-state + * export evidence: JS/TS export clauses and CommonJS can also store false. + * True for Swift `private`/`fileprivate`, a Zig declaration without + * `pub` (an `export fn` without `pub` is recorded true for its C linkage, so + * `false` never hides an FFI symbol). + * + * Absent where `false` is a narrower fact than "file-private": Java + * package-private, C# and Kotlin `internal`, Go lowercase (the rest of the + * package / assembly / module still sees it); Python and Dart `_name` (a + * convention, `from m import _x` works and a Dart `part` shares its + * library's privates); Rust (a child module reaches `super::` privates); + * C/C++ (`static` or an anonymous namespace in a header is visible to every + * file that `#include`s it). + * + * Read by consumers that drop a file-level fallback for a not-exported + * symbol — the owning-file seed of `impact` (#3602) — which must otherwise + * keep the fallback and over-approximate rather than miss a dependant. A + * capability on the provider rather than a language set in `mcp/`: the + * meaning of a stored `isExported` is a property of the language. + * + * Default: undefined, read as `false`. + */ + readonly notExportedIsFilePrivate?: boolean | ((filePath: string, content: string) => boolean); + // ── Calls & Imports (optional) ──────────────────────────────────── /** Call routing for languages that express imports/heritage as calls (e.g., Ruby). * Default: no routing (all calls are normal call expressions). */ diff --git a/gitnexus/src/core/ingestion/languages/swift.ts b/gitnexus/src/core/ingestion/languages/swift.ts index 57d06ee67..2ed90892d 100644 --- a/gitnexus/src/core/ingestion/languages/swift.ts +++ b/gitnexus/src/core/ingestion/languages/swift.ts @@ -237,6 +237,9 @@ export const swiftProvider = defineLanguage({ treeSitterQueries: SWIFT_QUERIES, typeConfig: swiftConfig, exportChecker: swiftExportChecker, + // `swiftExportChecker` returns false only for `private` / `fileprivate`, + // both file-scoped; the module-wide default (`internal`) is recorded true. + notExportedIsFilePrivate: true, callExtractor: createCallExtractor(swiftCallConfig), fieldExtractor: createFieldExtractor(swiftFieldConfig), methodExtractor: createMethodExtractor({ diff --git a/gitnexus/src/core/ingestion/languages/typescript.ts b/gitnexus/src/core/ingestion/languages/typescript.ts index 83b945b94..cf93dbcfd 100644 --- a/gitnexus/src/core/ingestion/languages/typescript.ts +++ b/gitnexus/src/core/ingestion/languages/typescript.ts @@ -79,6 +79,7 @@ const memberAssignmentLabel = (node: SyntaxNode): NodeLabel | null => { import { createTypeScriptCfgVisitor } from '../cfg/visitors/typescript.js'; import { typeConfig as typescriptConfig } from '../type-extractors/typescript.js'; import { tsExportChecker } from '../export-detection.js'; +import { hasFilePrivateExportSemantics } from './typescript/file-private-exports.js'; import { TYPESCRIPT_QUERIES, JAVASCRIPT_QUERIES } from '../tree-sitter-queries.js'; import { typescriptFieldExtractor } from '../field-extractors/typescript.js'; import { createFieldExtractor } from '../field-extractors/generic.js'; @@ -517,6 +518,7 @@ export const typescriptProvider = defineLanguage({ typeConfig: typescriptConfig, scopeOwnsReceivers: tsScopeOwnsReceivers, exportChecker: tsExportChecker, + notExportedIsFilePrivate: hasFilePrivateExportSemantics, callExtractor: createCallExtractor(typescriptCallConfig), fieldExtractor: typescriptFieldExtractor, methodExtractor: createMethodExtractor({ @@ -621,6 +623,7 @@ export const javascriptProvider = defineLanguage({ typeConfig: typescriptConfig, scopeOwnsReceivers: tsScopeOwnsReceivers, exportChecker: tsExportChecker, + notExportedIsFilePrivate: hasFilePrivateExportSemantics, callExtractor: createCallExtractor(javascriptCallConfig), fieldExtractor: createFieldExtractor(javascriptConfig), methodExtractor: createMethodExtractor({ diff --git a/gitnexus/src/core/ingestion/languages/typescript/file-private-exports.ts b/gitnexus/src/core/ingestion/languages/typescript/file-private-exports.ts new file mode 100644 index 000000000..1815faac1 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/typescript/file-private-exports.ts @@ -0,0 +1,45 @@ +import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js'; +import { getTreeSitterBufferSize } from '../../constants.js'; +import { collectEsmExportEvidence } from '../../ts-js-export-marker.js'; +import { getJsParser } from '../javascript/query.js'; +import { getTsParser } from './query.js'; + +/** Certify only modules whose exports the graph's syntactic checker can see. */ +export function hasFilePrivateExportSemantics(filePath: string, content: string): boolean { + // File.content is FTS-normalized: newlines become spaces. A line comment + // could therefore hide a later export from this parse. Never certify it. + if ( + !content || + content.includes('//') || + content.includes('[truncated]') || + /\.d\.[cm]?ts$/i.test(filePath) + ) + return false; + try { + const parser = /\.[cm]?jsx?$/i.test(filePath) ? getJsParser(filePath) : getTsParser(filePath); + const tree = parseSourceSafe(parser, content, undefined, { + bufferSize: getTreeSitterBufferSize(content), + }); + if (tree.rootNode.hasError) return false; + const evidence = collectEsmExportEvidence(tree.rootNode, filePath); + if (!evidence || evidence.commonJs) return false; + let isModule = false; + for (const statement of tree.rootNode.namedChildren) { + // Ambient modules and global augmentations publish otherwise unexported types. + if (statement.type === 'ambient_declaration') return false; + if (statement.type === 'import_statement') isModule = true; + if (statement.type !== 'export_statement') continue; + isModule = true; + // `export { T }`, `export default T`, and `export = T` may publish a + // declaration stored as false. Type aliases can do the same without a + // graph edge to the underlying type. Retain the file seed for the whole file. + const declaration = statement.childForFieldName('declaration'); + if (!declaration || declaration.type === 'type_alias_declaration') return false; + } + // Script declarations may be global across files without any exports. + return isModule; + } catch { + // Unreadable or unsupported source is no proof of privacy. + return false; + } +} diff --git a/gitnexus/src/core/ingestion/languages/zig.ts b/gitnexus/src/core/ingestion/languages/zig.ts index ac203cff9..f4e8abb9d 100644 --- a/gitnexus/src/core/ingestion/languages/zig.ts +++ b/gitnexus/src/core/ingestion/languages/zig.ts @@ -88,6 +88,9 @@ export const zigProvider = defineLanguage({ typeConfig: zigTypeConfig, cfgVisitor: createZigCfgVisitor(), exportChecker: zigExportChecker, + // No `pub` → unreachable through `@import` from another file. `export fn` + // without `pub` is recorded true (C linkage), so false never hides FFI. + notExportedIsFilePrivate: true, isExternalModuleSpecifier: (specifier) => ZIG_RESERVED_MODULES.has(specifier), callExtractor: createCallExtractor(zigCallConfig), classExtractor: createClassExtractor(zigClassConfig), diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index 8ab5bb786..81b7cd029 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -8524,9 +8524,28 @@ export class LocalBackend { // The owning File is kept only as an internal seed (frontier/visited) and // is NOT added to impacted — it is the definition container, not an // upstream dependent. The BFS will discover IMPORTS edges on it naturally. + // + // #3602: the file's importers stand in for the type's importers, which + // only holds when the type can leave the file. Where the index recorded + // `isExported = false` AND the language's provider confirms that verdict + // is file-private (checking indexed source where needed), no + // importer can name the type, so seeding its file would report every + // importer of the file as a direct dependant. The seed is skipped for it; + // its real dependants still arrive through USES/EXTENDS/IMPLEMENTS edges + // inside the file. Everything else keeps the seed: a missing verdict + // (NULL) proves nothing; JS/TS also need to exclude indirect exports, + // CommonJS and ambient files even when the stored flag is false. Where + // `false` means package-, assembly- or module-private (Java, C#, Kotlin, + // Go, …) a sibling file can still name the type, so the over-approximation + // is the safe side. Downstream still needs the file's imports: a private + // type can depend on other files even though they cannot import its name. if (symType === 'Class' || symType === 'Interface') { try { // Run both seed queries in parallel — they are independent. + // The label is literal: only these two tables reach this branch, and + // not every node table carries `isExported` (Protocol/Category do + // not), so an untyped `n.isExported` is not a safe projection. + const typeLabel = symType === 'Class' ? 'Class' : 'Interface'; const [ctorRows, fileRows] = await Promise.all([ executeParameterized( repo.lbugPath, @@ -8542,9 +8561,10 @@ export class LocalBackend { executeParameterized( repo.lbugPath, ` - MATCH (f:File)-[rel:CodeRelation]->(n) + MATCH (f:File)-[rel:CodeRelation]->(n:${typeLabel}) WHERE n.id = $symId AND rel.type = 'DEFINES' - RETURN f.id AS id, f.name AS name, labels(f)[0] AS type, f.filePath AS filePath + RETURN f.id AS id, f.name AS name, labels(f)[0] AS type, f.filePath AS filePath, n.isExported AS isExported, + CASE WHEN n.isExported = false THEN f.content ELSE NULL END AS content `, { symId }, ), @@ -8560,6 +8580,21 @@ export class LocalBackend { } for (const r of fileRows) { assertSymbolRowIdentity(r); + const exported = r.isExported ?? r[4]; + if (direction === 'upstream' && exported === false) { + // Lazy like the other provider lookups on this backend: the + // registry pulls every language module, and this branch is the + // only reader on the impact path. + const { getProviderForFile } = await import('../../core/ingestion/languages/index.js'); + const filePath = String(r.filePath ?? r[3] ?? ''); + const filePrivate = getProviderForFile(filePath)?.notExportedIsFilePrivate; + if ( + filePrivate === true || + (typeof filePrivate === 'function' && + filePrivate(filePath, String(r.content ?? r[5] ?? ''))) + ) + continue; + } const rid = r.id || r[0]; if (rid && !visited.has(rid)) { visited.add(rid); diff --git a/gitnexus/test/integration/impact-private-type-file-seed.test.ts b/gitnexus/test/integration/impact-private-type-file-seed.test.ts new file mode 100644 index 000000000..24549d13f --- /dev/null +++ b/gitnexus/test/integration/impact-private-type-file-seed.test.ts @@ -0,0 +1,252 @@ +import { beforeAll, expect, it, vi } from 'vitest'; +import { LocalBackend } from '../../src/mcp/local/local-backend.js'; +import { listRegisteredRepos } from '../../src/storage/repo-manager.js'; +import { withTestLbugDB, type IndexedDBHandle } from '../helpers/test-indexed-db.js'; + +vi.mock('../../src/storage/repo-manager.js', () => ({ + listRegisteredRepos: vi.fn().mockResolvedValue([]), + cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }), + findSiblingClones: vi.fn().mockResolvedValue([]), +})); + +// #3602 repro 2: `src/lib/svc.ts` declares a module-private interface `Entry`, +// used by `parse`, which `verify` (the file's only export) calls. Eight route +// files import `verify`. The owning-file seed (#480) turned those eight +// importers into direct dependants of `Entry`, a name none of them can reach. +const ROUTE_COUNT = 8; +const SIBLING_COUNT = 3; +const SIBLING_EXTENSION: Record = { + java: 'java', + kotlin: 'kt', + cjs: 'js', + script: 'ts', +}; +const routeFiles = Array.from( + { length: ROUTE_COUNT }, + (_, index) => + `CREATE (f:File {id: 'File:src/app/v${index}/route.ts', name: 'route.ts', filePath: 'src/app/v${index}/route.ts', content: ''})`, +); +const routeImports = Array.from( + { length: ROUTE_COUNT }, + (_, index) => + `MATCH (a:File {id:'File:src/app/v${index}/route.ts'}), (b:File {id:'File:src/lib/svc.ts'}) CREATE (a)-[:CodeRelation {type:'IMPORTS', confidence:1.0, reason:'import', step:0}]->(b)`, +); + +const SEED = [ + `CREATE (f:File {id: 'File:src/lib/svc.ts', name: 'svc.ts', filePath: 'src/lib/svc.ts', content: 'interface Entry {}\\nexport function verify() {}'})`, + ...routeFiles, + ...routeImports, + `CREATE (:File {id: 'File:src/lib/dependency.ts', name: 'dependency.ts', filePath: 'src/lib/dependency.ts', content: ''})`, + `MATCH (a:File {id:'File:src/lib/svc.ts'}), (b:File {id:'File:src/lib/dependency.ts'}) CREATE (a)-[:CodeRelation {type:'IMPORTS', confidence:1.0, reason:'import', step:0}]->(b)`, + // Module-private: a recorded `false` verdict. + `CREATE (i:Interface {id: 'Interface:src/lib/svc.ts:Entry', name: 'Entry', filePath: 'src/lib/svc.ts', startLine: 1, endLine: 3, isExported: false, content: '', description: ''})`, + // Exported sibling in the same file: the file seed must stay for it. + `CREATE (i:Interface {id: 'Interface:src/lib/svc.ts:Options', name: 'Options', filePath: 'src/lib/svc.ts', startLine: 4, endLine: 6, isExported: true, content: '', description: ''})`, + // No verdict at all (NULL): the seed stays, as before. + `CREATE (i:Interface {id: 'Interface:src/lib/svc.ts:Legacy', name: 'Legacy', filePath: 'src/lib/svc.ts', startLine: 7, endLine: 9, content: '', description: ''})`, + `CREATE (fn:Function {id: 'Function:src/lib/svc.ts:parse', name: 'parse', filePath: 'src/lib/svc.ts', startLine: 10, endLine: 12, isExported: false, content: '', description: ''})`, + `CREATE (fn:Function {id: 'Function:src/lib/svc.ts:verify', name: 'verify', filePath: 'src/lib/svc.ts', startLine: 13, endLine: 15, isExported: true, content: '', description: ''})`, + ...['Entry', 'Options', 'Legacy'].map( + (name) => + `MATCH (f:File {id:'File:src/lib/svc.ts'}), (i:Interface {id:'Interface:src/lib/svc.ts:${name}'}) CREATE (f)-[:CodeRelation {type:'DEFINES', confidence:1.0, reason:'', step:0}]->(i)`, + ), + `MATCH (a:Function {id:'Function:src/lib/svc.ts:parse'}), (b:Interface {id:'Interface:src/lib/svc.ts:Entry'}) CREATE (a)-[:CodeRelation {type:'USES', confidence:1.0, reason:'type-reference', step:0}]->(b)`, + `MATCH (a:Function {id:'Function:src/lib/svc.ts:verify'}), (b:Function {id:'Function:src/lib/svc.ts:parse'}) CREATE (a)-[:CodeRelation {type:'CALLS', confidence:1.0, reason:'direct', step:0}]->(b)`, + // Sibling path: `false` that is NOT file-private. A Java package-private + // class and a Kotlin `internal` class are both recorded `isExported: false` + // and both stay nameable from the other files of their package / module, so + // the file seed must survive for them. + // Each file also declares a public class, so the file has a `true` row the + // way an ES module does: only the provider's verdict keeps the seed here. + ...packageScopedCase('java', 'src/main/java/app/Svc.java', 'PackageEntry', 'JavaApi'), + ...packageScopedCase('kotlin', 'src/main/kotlin/app/Svc.kt', 'InternalEntry', 'KotlinApi'), + // Same `false`, same provider flag, but the file's export surface is not an + // ES one: a CommonJS module (`module.exports = Widget`) and a global script + // record every declaration `false` and never a `true`. Both stay nameable + // from other files, so the seed must survive for them. + ...packageScopedCase('cjs', 'src/legacy/widget.js', 'Widget'), + ...packageScopedCase('script', 'src/global/registry.ts', 'Registry'), +]; + +function siblingFiles(lang: string): string[] { + return Array.from( + { length: SIBLING_COUNT }, + (_, index) => `src/use/${lang}/Use${index}.${SIBLING_EXTENSION[lang]}`, + ); +} + +function packageScopedCase( + lang: string, + ownerPath: string, + className: string, + exportedSibling?: string, +): string[] { + const ownerId = `File:${ownerPath}`; + const classId = `Class:${ownerPath}:${className}`; + const siblingId = `Class:${ownerPath}:${exportedSibling}`; + return [ + `CREATE (f:File {id: '${ownerId}', name: '${ownerPath.slice(ownerPath.lastIndexOf('/') + 1)}', filePath: '${ownerPath}', content: ''})`, + `CREATE (c:Class {id: '${classId}', name: '${className}', filePath: '${ownerPath}', startLine: 1, endLine: 3, isExported: false, content: '', description: ''})`, + `MATCH (f:File {id:'${ownerId}'}), (c:Class {id:'${classId}'}) CREATE (f)-[:CodeRelation {type:'DEFINES', confidence:1.0, reason:'', step:0}]->(c)`, + ...(exportedSibling === undefined + ? [] + : [ + `CREATE (c:Class {id: '${siblingId}', name: '${exportedSibling}', filePath: '${ownerPath}', startLine: 4, endLine: 6, isExported: true, content: '', description: ''})`, + `MATCH (f:File {id:'${ownerId}'}), (c:Class {id:'${siblingId}'}) CREATE (f)-[:CodeRelation {type:'DEFINES', confidence:1.0, reason:'', step:0}]->(c)`, + ]), + ...siblingFiles(lang).flatMap((filePath) => [ + `CREATE (f:File {id: 'File:${filePath}', name: '${filePath.slice(filePath.lastIndexOf('/') + 1)}', filePath: '${filePath}', content: ''})`, + `MATCH (a:File {id:'File:${filePath}'}), (b:File {id:'${ownerId}'}) CREATE (a)-[:CodeRelation {type:'IMPORTS', confidence:1.0, reason:'import', step:0}]->(b)`, + ]), + ]; +} + +type BackendHandle = IndexedDBHandle & { _backend?: LocalBackend }; + +type ImpactByDepth = { byDepth?: Record> }; + +const ids = (result: ImpactByDepth, depth: number): string[] => + (result.byDepth?.[depth] ?? []).map((item) => String(item.id)).sort(); + +withTestLbugDB( + 'impact-private-type-file-seed', + (handle) => { + let backend: LocalBackend; + beforeAll(() => { + const ext = handle as BackendHandle; + if (!ext._backend) throw new Error('LocalBackend not initialized'); + backend = ext._backend; + }); + + it('does not report the importers of the file as direct dependants of a module-private interface', async () => { + const result = await backend.callTool('impact', { + target: 'Entry', + kind: 'Interface', + direction: 'upstream', + }); + + expect(result.target.id).toBe('Interface:src/lib/svc.ts:Entry'); + expect(ids(result, 1)).toEqual(['Function:src/lib/svc.ts:parse']); + expect(ids(result, 2)).toEqual(['Function:src/lib/svc.ts:verify']); + expect(result.impactedCount).toBe(2); + expect(result.summary.direct).toBe(1); + expect(result.risk).toBe('LOW'); + }); + + it('keeps the file seed for an exported interface of the same file', async () => { + const result = await backend.callTool('impact', { + target: 'Options', + kind: 'Interface', + direction: 'upstream', + }); + + expect(ids(result, 1)).toEqual( + Array.from({ length: ROUTE_COUNT }, (_, index) => `File:src/app/v${index}/route.ts`).sort(), + ); + expect(result.impactedCount).toBe(ROUTE_COUNT); + }); + + it('preserves downstream file dependencies even for a private type', async () => { + const result = await backend.callTool('impact', { + target: 'Entry', + kind: 'Interface', + direction: 'downstream', + }); + expect(ids(result, 1)).toEqual(['File:src/lib/dependency.ts']); + }); + + it('keeps the file seed when the index recorded no export verdict', async () => { + const result = await backend.callTool('impact', { + target: 'Legacy', + kind: 'Interface', + direction: 'upstream', + }); + + expect(result.impactedCount).toBe(ROUTE_COUNT); + expect(ids(result, 1).every((id) => id.startsWith('File:src/app/'))).toBe(true); + }); + + it('keeps the file seed for a Java package-private class: false is not file-private there', async () => { + const result = await backend.callTool('impact', { + target: 'PackageEntry', + kind: 'Class', + direction: 'upstream', + }); + + expect(result.target.id).toBe('Class:src/main/java/app/Svc.java:PackageEntry'); + expect(ids(result, 1)).toEqual( + siblingFiles('java') + .map((p) => `File:${p}`) + .sort(), + ); + expect(result.impactedCount).toBe(SIBLING_COUNT); + }); + + it('keeps the file seed for a Kotlin internal class: false is module-wide there', async () => { + const result = await backend.callTool('impact', { + target: 'InternalEntry', + kind: 'Class', + direction: 'upstream', + }); + + expect(result.target.id).toBe('Class:src/main/kotlin/app/Svc.kt:InternalEntry'); + expect(ids(result, 1)).toEqual( + siblingFiles('kotlin') + .map((p) => `File:${p}`) + .sort(), + ); + expect(result.impactedCount).toBe(SIBLING_COUNT); + }); + + it('keeps the file seed for a class of a CommonJS module: no ES export in the file, so false is no verdict', async () => { + const result = await backend.callTool('impact', { + target: 'Widget', + kind: 'Class', + direction: 'upstream', + }); + + expect(result.target.id).toBe('Class:src/legacy/widget.js:Widget'); + expect(ids(result, 1)).toEqual( + siblingFiles('cjs') + .map((p) => `File:${p}`) + .sort(), + ); + expect(result.impactedCount).toBe(SIBLING_COUNT); + }); + + it('keeps the file seed for a declaration of a global TS script for the same reason', async () => { + const result = await backend.callTool('impact', { + target: 'Registry', + kind: 'Class', + direction: 'upstream', + }); + + expect(result.target.id).toBe('Class:src/global/registry.ts:Registry'); + expect(ids(result, 1)).toEqual( + siblingFiles('script') + .map((p) => `File:${p}`) + .sort(), + ); + expect(result.impactedCount).toBe(SIBLING_COUNT); + }); + }, + { + seed: SEED, + poolAdapter: true, + afterSetup: async (handle) => { + vi.mocked(listRegisteredRepos).mockResolvedValue([ + { + name: 'test-repo', + path: '/test/repo', + storagePath: handle.tmpHandle.dbPath, + indexedAt: new Date().toISOString(), + lastCommit: 'abc123', + stats: { files: ROUTE_COUNT + 1, nodes: ROUTE_COUNT + 6, communities: 0, processes: 0 }, + }, + ]); + const backend = new LocalBackend(); + await backend.init(); + (handle as BackendHandle)._backend = backend; + }, + }, +); diff --git a/gitnexus/test/integration/impact-private-type-source.test.ts b/gitnexus/test/integration/impact-private-type-source.test.ts new file mode 100644 index 000000000..b5e467a5d --- /dev/null +++ b/gitnexus/test/integration/impact-private-type-source.test.ts @@ -0,0 +1,142 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { expect, it, vi } from 'vitest'; +import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; +import { LocalBackend } from '../../src/mcp/local/local-backend.js'; +import { listRegisteredRepos } from '../../src/storage/repo-manager.js'; +import { withTestLbugDB } from '../helpers/test-indexed-db.js'; + +vi.mock('../../src/storage/repo-manager.js', async (importOriginal) => ({ + ...(await importOriginal()), + listRegisteredRepos: vi.fn().mockResolvedValue([]), + cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }), + findSiblingClones: vi.fn().mockResolvedValue([]), +})); + +const sources: Record = { + 'clause.ts': 'interface ClauseEntry { value: string }\nexport { ClauseEntry };\n', + 'commented.ts': + 'interface CommentedEntry {}\nexport function verifyCommented() {}\n// exported separately\nexport { CommentedEntry };\n', + 'commented-use.ts': + "import type { CommentedEntry } from './commented';\nexport type Alias = CommentedEntry;\n", + 'clause-use.ts': + "import type { ClauseEntry } from './clause';\nexport function consume(x: ClauseEntry) { return x.value; }\n", + 'default.ts': 'class DefaultEntry {}\nexport default DefaultEntry;\n', + 'default-use.ts': "import Entry from './default';\nexport { Entry };\n", + 'type-alias.ts': + 'interface AliasedEntry { value: string }\nexport type PublicEntry = AliasedEntry;\n', + 'type-alias-use.ts': + "import type { PublicEntry } from './type-alias';\nexport function consumeAlias(x: PublicEntry) { return x.value; }\n", + 'wrapped-alias.ts': + "class AliasedClass { value = ''; }\nexport type PublicClass = Readonly;\n", + 'wrapped-alias-use.ts': + "import type { PublicClass } from './wrapped-alias';\nexport function consumeWrapped(x: PublicClass) { return x.value; }\n", + 'value-alias.js': + 'class ValueAliasedClass {}\nexport const PublishedClass = ValueAliasedClass;\n', + 'value-alias-use.js': + "import { PublishedClass } from './value-alias';\nexport function constructAlias() { return new PublishedClass(); }\n", + 'cjs.js': 'class CjsEntry {}\nmodule.exports = CjsEntry;\n', + 'cjs-use.js': "const Entry = require('./cjs');\nmodule.exports = Entry;\n", + 'ambient.d.ts': 'interface AmbientEntry { value: string }\n', + 'global.ts': "import './side';\ndeclare global { interface GlobalEntry { value: string } }\n", + 'side.ts': 'export const side = true;\n', + 'global-use.ts': + "import './global';\nexport function consumeGlobal(x: GlobalEntry) { return x.value; }\n", + 'ambient-use.ts': + "import type { AmbientEntry } from './ambient';\nexport function consumeAmbient(x: AmbientEntry) { return x.value; }\n", + 'private.ts': + 'interface PrivateEntry { value: string }\nexport function verify() { return true; }\n', + 'private-use.ts': + "import { verify } from './private';\nexport function check() { return verify(); }\n", + 'private.js': 'class PrivateClass {}\nexport function verifyClass() { return true; }\n', + 'private-js-use.js': + "import { verifyClass } from './private.js';\nexport function checkClass() { return verifyClass(); }\n", +}; + +let backend: LocalBackend; +let repoPath: string; +const exportFlags = new Map(); + +withTestLbugDB( + 'impact-private-type-source', + () => { + it.each([ + ['ClauseEntry', 'clause-use.ts'], + ['CommentedEntry', 'commented-use.ts'], + ['DefaultEntry', 'default-use.ts'], + ['AliasedEntry', 'type-alias-use.ts'], + ['AliasedClass', 'wrapped-alias-use.ts'], + ['CjsEntry', 'cjs-use.js'], + ['AmbientEntry', 'ambient-use.ts'], + ['GlobalEntry', 'global-use.ts'], + ])('retains importers for %s despite the parser storing false', async (target, importer) => { + expect(exportFlags.get(target)).toBe(false); + const result = await backend.callTool('impact', { target, direction: 'upstream' }); + expect( + (result.byDepth[1] ?? []) + .filter((item: { id: string }) => item.id.startsWith('File:')) + .map((item: { id: string }) => item.id), + ).toEqual([`File:${importer}`]); + }); + + it('retains importers of a class exposed through an exported value alias', async () => { + expect(exportFlags.get('ValueAliasedClass')).toBe(false); + const result = await backend.callTool('impact', { + target: 'ValueAliasedClass', + direction: 'upstream', + }); + expect( + Object.values(result.byDepth as Record) + .flat() + .map((item) => item.id), + ).toContain('File:value-alias-use.js'); + }); + + it.each(['PrivateEntry', 'PrivateClass'])( + 'does not borrow file importers for %s', + async (target) => { + expect(exportFlags.get(target)).toBe(false); + const result = await backend.callTool('impact', { target, direction: 'upstream' }); + expect(result.impactedCount).toBe(0); + expect(result.risk).toBe('UNKNOWN'); + }, + ); + }, + { + poolAdapter: true, + beforeFTS: async (dbPath) => { + const storageDir = path.dirname(dbPath); + repoPath = path.join(storageDir, 'source'); + await fs.mkdir(repoPath); + await Promise.all( + Object.entries(sources).map(([name, content]) => + fs.writeFile(path.join(repoPath, name), content), + ), + ); + const result = await runPipelineFromRepo(repoPath, () => {}, { + skipGraphPhases: true, + workerPoolSize: 1, + }); + for (const node of result.graph.nodes) { + if (node.label === 'Class' || node.label === 'Interface') { + exportFlags.set(node.properties.name, node.properties.isExported); + } + } + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + await adapter.loadGraphToLbug(result.graph, repoPath, storageDir); + }, + afterSetup: async (handle) => { + vi.mocked(listRegisteredRepos).mockResolvedValue([ + { + name: 'private-source', + path: repoPath, + storagePath: handle.tmpHandle.dbPath, + indexedAt: new Date().toISOString(), + lastCommit: 'abc123', + }, + ]); + backend = new LocalBackend(); + await backend.init(); + }, + }, +); diff --git a/gitnexus/test/unit/file-private-exports.test.ts b/gitnexus/test/unit/file-private-exports.test.ts new file mode 100644 index 000000000..7034d34fe --- /dev/null +++ b/gitnexus/test/unit/file-private-exports.test.ts @@ -0,0 +1,53 @@ +import { expect, it } from 'vitest'; +import { hasFilePrivateExportSemantics } from '../../src/core/ingestion/languages/typescript/file-private-exports.js'; + +it.each([ + ['plain module', 'entry.ts', 'interface Entry {}\nexport function verify() {}', true], + ['JS module', 'entry.js', 'class Entry {}\nexport function verify() {}', true], + ['TSX module', 'entry.tsx', 'interface Entry {}\nexport const View = () =>
;', true], + ['JSX module', 'entry.jsx', 'class Entry {}\nexport const View = () =>
;', true], + ['named export', 'entry.ts', 'interface Entry {}\nexport { Entry };', false], + ['renamed export', 'entry.ts', 'interface Entry {}\nexport { Entry as Public };', false], + ['default export', 'entry.ts', 'class Entry {}\nexport default Entry;', false], + ['assignment export', 'entry.ts', 'class Entry {}\nexport = Entry;', false], + ['type alias', 'entry.ts', 'interface Entry {}\nexport type Public = Entry;', false], + [ + 'wrapped type alias', + 'entry.ts', + 'class Entry {}\nexport type Public = Readonly;', + false, + ], + ['CommonJS', 'entry.js', 'class Entry {}\nmodule.exports = Entry;', false], + ['bracket CommonJS', 'entry.js', "class Entry {}\nmodule['exports'] = Entry;", false], + [ + 'mixed CommonJS', + 'entry.js', + 'class Entry {}\nexports.Entry = Entry;\nexport const n = 1;', + false, + ], + ['ambient', 'entry.d.ts', 'interface Entry {}\nexport const n: number;', false], + ['ambient ESM', 'entry.d.mts', 'interface Entry {}\nexport const n: number;', false], + ['script global', 'entry.ts', 'interface Entry {}', false], + [ + 'global augmentation', + 'entry.ts', + "import './side'; declare global { interface Entry {} }", + false, + ], + ['missing retained source', 'entry.ts', '', false], + [ + 'normalized line comment', + 'entry.ts', + 'interface Entry {} export function verify() {} // comment export { Entry };', + false, + ], + [ + 'truncated source', + 'entry.ts', + 'interface Entry {} export function verify() {} /* [truncated] */', + false, + ], + ['invalid source', 'entry.ts', 'interface Entry {\nexport const', false], +])('%s has conservative file privacy', (_label, filePath, content, expected) => { + expect(hasFilePrivateExportSemantics(filePath, content)).toBe(expected); +}); diff --git a/gitnexus/vitest.config.ts b/gitnexus/vitest.config.ts index a9c3fa2ed..d26305145 100644 --- a/gitnexus/vitest.config.ts +++ b/gitnexus/vitest.config.ts @@ -79,6 +79,8 @@ export default defineConfig({ 'test/integration/pdg-explicit-owner.test.ts', 'test/integration/skip-fts.test.ts', 'test/integration/impact-callable-value-references.test.ts', + 'test/integration/impact-private-type-file-seed.test.ts', + 'test/integration/impact-private-type-source.test.ts', 'test/integration/impact-context-integrity.test.ts', 'test/integration/incremental-worktree-context.test.ts', 'test/integration/incremental-method-integrity.test.ts', @@ -188,6 +190,8 @@ export default defineConfig({ 'test/integration/pdg-explicit-owner.test.ts', 'test/integration/skip-fts.test.ts', 'test/integration/impact-callable-value-references.test.ts', + 'test/integration/impact-private-type-file-seed.test.ts', + 'test/integration/impact-private-type-source.test.ts', 'test/integration/impact-context-integrity.test.ts', 'test/integration/incremental-worktree-context.test.ts', 'test/integration/incremental-method-integrity.test.ts',