diff --git a/libs/openant-core/parsers/javascript/test_pipeline.py b/libs/openant-core/parsers/javascript/test_pipeline.py index 2eee6bd8..d71509f5 100644 --- a/libs/openant-core/parsers/javascript/test_pipeline.py +++ b/libs/openant-core/parsers/javascript/test_pipeline.py @@ -877,8 +877,11 @@ def apply_codeql_filter(self) -> bool: # Build mapping of file -> [(start_line, end_line, func_id)] file_functions = {} for func_id, func_data in functions.items(): - # Extract file path from func_id (format: "file/path.ts:functionName") - colon_idx = func_id.rfind(":") + # Extract file path from func_id (format: "file/path.ts:functionName"). + # The separator is the FIRST colon: functionName may itself contain + # colons (e.g. "src/r.ts:express(GET:/items/:id)"). This matches the + # dependency_resolver contract (`funcId.split(':')[0]`). + colon_idx = func_id.find(":") if colon_idx > 0: file_path = func_id[:colon_idx] start_line = func_data.get('startLine', 0) diff --git a/libs/openant-core/parsers/javascript/typescript_analyzer.js b/libs/openant-core/parsers/javascript/typescript_analyzer.js index 8b6b0e77..baf219c8 100644 --- a/libs/openant-core/parsers/javascript/typescript_analyzer.js +++ b/libs/openant-core/parsers/javascript/typescript_analyzer.js @@ -59,7 +59,12 @@ class TypeScriptAnalyzer { }); this.functions = {}; // functionId -> function metadata this.classes = {}; // "filePath:className" -> { constructorDeps, fieldDeps, baseTypes } - this.callGraph = {}; // callerId -> array of call info + this.callGraph = {}; // callerId -> array of call info { resolved, name } + // Resolved bidirectional graph (snake_case, list-of-resolved-ids) matching + // the C/Python/Ruby sibling contract consumed by the Python pipeline. + this.resolvedCallGraph = {}; // callerId -> [resolvedCalleeId] + this.reverseCallGraph = {}; // calleeId -> [callerId] + this.indirectCalls = {}; // callerId -> [unresolved/dynamic call names] } /** @@ -100,16 +105,24 @@ class TypeScriptAnalyzer { } /** - * Check if function has route handler signature (req, res) or (request, response) + * Check if a function's parameter shape matches a known web-framework route + * handler (Express / Koa / Fastify). + * + * The bare `(request, response)` pattern was removed: it is ambiguous and + * misfires on React components (`function MyComponent(request, response)`) + * which share those parameter names. Fastify's distinctive + * `(request, reply)` shape is added. The + * remaining patterns (`(req, res)`, Koa `(ctx)`, TS `: Request`/`: Response` + * type annotations) are framework-specific enough to be reliable. */ _hasRouteHandlerSignature(code) { - // Match common Express handler patterns const handlerPatterns = [ - /\(\s*req\s*,\s*res\s*[,\)]/, // (req, res) or (req, res, next) - /\(\s*request\s*,\s*response\s*[,\)]/, // (request, response) + /\(\s*req\s*,\s*res\s*[,\)]/, // Express (req, res) / (req, res, next) + /\(\s*request\s*,\s*reply\s*[,\)]/, // Fastify (request, reply) /\(\s*ctx\s*[,\)]/, // Koa style (ctx) /:\s*Request\s*,/, // TypeScript: Request type /:\s*Response\s*[,\)]/, // TypeScript: Response type + /:\s*FastifyRequest\b/, // TypeScript Fastify: FastifyRequest type ]; return handlerPatterns.some((pattern) => pattern.test(code)); } @@ -162,10 +175,44 @@ class TypeScriptAnalyzer { this.buildCallGraphForFile(sourceFile); } + // Step 4: Backstop the Pattern-A companion invariant — every function in + // the inventory must have a callGraph key. + // The emit-time companions above cover the AST-walked paths; this fills any + // remaining function (e.g. re-export references) with an empty edge list so + // `len(callGraph) === len(functions)` always holds. + for (const functionId of Object.keys(this.functions)) { + if (!(functionId in this.callGraph)) { + this.callGraph[functionId] = []; + } + } + + // Step 4.5: Stamp the snake_case schema-contract fields downstream Python + // consumers expect. EntryPointDetector keys off `unit_type`; + // without it no Express route is ever seen as an entry point and every + // reachable callee is filtered out. We keep the camelCase `unitType` too so + // the JS unit_generator continues to work. + for (const funcData of Object.values(this.functions)) { + if (funcData.unit_type === undefined) { + funcData.unit_type = funcData.unitType || "function"; + } + if (funcData.parameters === undefined) { + funcData.parameters = []; + } + } + + // Step 5: Build the resolved bidirectional graph + per-function metadata + // expected by downstream Python consumers (snake_case call_graph / + // reverse_call_graph of resolved ids, repository, per-fn parameters). + this._buildResolvedGraphs(); + return { + repository: this.repoPath, functions: this.functions, classes: this.classes, callGraph: this.callGraph, + call_graph: this.resolvedCallGraph, + reverse_call_graph: this.reverseCallGraph, + indirect_calls: this.indirectCalls, }; } @@ -194,18 +241,24 @@ class TypeScriptAnalyzer { unitType: this.classifyFunction(name, code, false, null), startLine: func.getStartLineNumber(), endLine: func.getEndLineNumber(), + parameters: this._extractParameters(func), }; } - // Extract arrow functions assigned to variables/constants + // Extract arrow functions assigned to variables/constants. + // Also covers Higher-Order-Component wrappers whose initializer is a call + // expression containing an inline function — `const X = memo(() => {})`, + // `forwardRef((p, r) => {})`, `styled.div\`...\``. for (const statement of sourceFile.getVariableStatements()) { for (const declaration of statement.getDeclarations()) { const initializer = declaration.getInitializer(); - if ( - initializer && - (initializer.getKindName() === "ArrowFunction" || - initializer.getKindName() === "FunctionExpression") - ) { + if (!initializer) continue; + const initKind = initializer.getKindName(); + const isDirectFunction = + initKind === "ArrowFunction" || initKind === "FunctionExpression"; + const isHocWrapper = + !isDirectFunction && this._isFunctionProducingInitializer(initializer); + if (isDirectFunction || isHocWrapper) { const name = declaration.getName(); const code = statement.getFullText(); const functionId = `${relativePath}:${name}`; @@ -218,6 +271,9 @@ class TypeScriptAnalyzer { unitType: this.classifyFunction(name, code, false, null), startLine: statement.getStartLineNumber(), endLine: statement.getEndLineNumber(), + parameters: isDirectFunction + ? this._extractParameters(initializer) + : [], }; } } @@ -227,7 +283,14 @@ class TypeScriptAnalyzer { for (const classDecl of sourceFile.getClasses()) { const className = classDecl.getName() || "AnonymousClass"; - for (const method of classDecl.getMethods()) { + // getMethods() excludes get/set accessors, so iterate the + // accessor lists too. They share the member-naming contract (Class.name). + const classMembers = [ + ...classDecl.getMethods(), + ...classDecl.getGetAccessors(), + ...classDecl.getSetAccessors(), + ]; + for (const method of classMembers) { const methodName = method.getName(); const code = method.getFullText(); const functionId = `${relativePath}:${className}.${methodName}`; @@ -240,6 +303,7 @@ class TypeScriptAnalyzer { startLine: method.getStartLineNumber(), endLine: method.getEndLineNumber(), className: className, + parameters: this._extractParameters(method), }; } @@ -340,11 +404,427 @@ class TypeScriptAnalyzer { // Extract anonymous callbacks used as Express route handlers / middleware // Pattern: app.get('/x', auth, async (req, res) => {...}) this._extractExpressRouteCallbacks(sourceFile, relativePath); + + // Extract methods from class *expressions* (module.exports = class {...}, + // const X = class {...}) which getClasses() does not return. + this._extractClassExpressionMethods(sourceFile, relativePath); + + // Extract anonymous `export default function(){...}`. + this._extractAnonymousDefaultExport(sourceFile, relativePath); + + // Extract prototype / this-assignment / Object.assign / defineProperty + // method shapes that aren't class members or top-level functions. + this._extractAssignedMethods(sourceFile, relativePath); + + // Extract a synthetic unit for files whose only meaningful content is a + // bare top-level side-effect call, e.g. preload scripts calling + // contextBridge.exposeInMainWorld({...}). + this._extractTopLevelSideEffects(sourceFile, relativePath); + } + + /** + * True if `node` is a call/tagged-template whose argument list contains an + * inline function — the Higher-Order-Component pattern. Examples: + * memo(() => {}) forwardRef((p, r) => {}) + * React.memo(function(){}) styled.div`...` + */ + _isFunctionProducingInitializer(node) { + if (!node) return false; + const kind = node.getKindName(); + if (kind === "CallExpression") { + const args = node.getArguments ? node.getArguments() : []; + return args.some((a) => { + const k = a.getKindName(); + return k === "ArrowFunction" || k === "FunctionExpression"; + }); + } + if (kind === "TaggedTemplateExpression") { + // styled.div`...` / css`...` — component-producing template tags. + return true; + } + return false; + } + + /** + * True if `node` is a re-export reference value, e.g. `require('./x').foo` + * or a bare identifier/property-access that names an exported symbol. + */ + _isReexportInitializer(node) { + if (!node) return false; + const kind = node.getKindName(); + if (kind === "PropertyAccessExpression") { + // require('./x').foo or mod.foo + return /require\s*\(|\./.test(node.getText()); + } + return false; + } + + /** + * Extract methods declared inside class *expressions* (not ClassDeclarations, + * which getClasses() already covers). Names the class from the binding it is + * assigned to where possible, else "AnonymousClass". + */ + _extractClassExpressionMethods(sourceFile, relativePath) { + const classExprs = sourceFile.getDescendantsOfKind( + ts.SyntaxKind.ClassExpression, + ); + for (const classExpr of classExprs) { + const className = + (classExpr.getName && classExpr.getName()) || + this._inferAssignedName(classExpr) || + "AnonymousClass"; + + const methods = classExpr.getMethods ? classExpr.getMethods() : []; + for (const method of methods) { + const methodName = method.getName(); + const functionId = `${relativePath}:${className}.${methodName}`; + if (this.functions[functionId]) continue; + const code = method.getFullText(); + this.functions[functionId] = { + name: `${className}.${methodName}`, + code: code, + isExported: false, + unitType: this.classifyFunction(methodName, code, true, className), + startLine: method.getStartLineNumber(), + endLine: method.getEndLineNumber(), + className: className, + }; + this.callGraph[functionId] = this.extractCallsFromFunction( + method, + relativePath, + ); + } + } + } + + /** + * Infer the binding name a node is assigned to: + * const X = -> "X" + * module.exports = -> "exports" + */ + _inferAssignedName(node) { + const parent = node.getParent && node.getParent(); + if (!parent) return null; + const pk = parent.getKindName(); + if (pk === "VariableDeclaration" && parent.getName) { + return parent.getName(); + } + if (pk === "BinaryExpression" && parent.getLeft) { + const leftText = parent.getLeft().getText(); + if (leftText === "module.exports" || leftText === "exports") { + return "exports"; + } + return leftText; + } + return null; + } + + /** + * Extract anonymous `export default function(){...}`. + * + * ts-morph parses `export default function(){}` as a *nameless* exported + * FunctionDeclaration (the named-declaration loop skips it via + * `if (!name) continue`), and parses `export default () => {}` as an + * ExportAssignment. Handle both. + */ + _extractAnonymousDefaultExport(sourceFile, relativePath) { + const emit = (node, code, bodyNode) => { + const functionId = `${relativePath}:default`; + if (this.functions[functionId]) return; + this.functions[functionId] = { + name: "default", + code: code, + isExported: true, + unitType: this.classifyFunction("default", code, false, null), + startLine: node.getStartLineNumber(), + endLine: node.getEndLineNumber(), + exportType: "default", + }; + this.callGraph[functionId] = this.extractCallsFromFunction( + bodyNode, + relativePath, + ); + }; + + // Nameless default-exported function declaration. + for (const func of sourceFile.getFunctions()) { + if (func.getName()) continue; + if (func.isDefaultExport && func.isDefaultExport()) { + emit(func, func.getFullText(), func); + } + } + + // `export default () => {}` / `export default function(){}` expressions. + for (const exportDecl of sourceFile.getExportAssignments()) { + if (exportDecl.isExportEquals && exportDecl.isExportEquals()) continue; + const expr = exportDecl.getExpression(); + if (!expr) continue; + const k = expr.getKindName(); + if (k === "ArrowFunction" || k === "FunctionExpression") { + emit(exportDecl, exportDecl.getFullText(), expr); + } + } } /** - * Express HTTP verbs we recognise on a router/app object. - * `use` is included to pick up middleware-mount callbacks. + * Extract function-valued assignments that aren't class members or named + * declarations: + * Foo.prototype.bar = function(){} + * function Ctor(){ this.method = fn; } + * Object.assign(Foo.prototype, { m(){}, n: fn }) + * Object.defineProperty(X.prototype, "n", { value: fn }) + * + * We walk every CallExpression / BinaryExpression descendant so the shape is + * found regardless of whether it lives at top level or inside a constructor + * function body. + */ + _extractAssignedMethods(sourceFile, relativePath) { + // 1. Assignment expressions: X.prototype.m = fn / this.m = fn + for (const bin of sourceFile.getDescendantsOfKind( + ts.SyntaxKind.BinaryExpression, + )) { + if (bin.getOperatorToken().getText() !== "=") continue; + const left = bin.getLeft(); + const right = bin.getRight(); + if (!left || left.getKindName() !== "PropertyAccessExpression") continue; + const rk = right && right.getKindName(); + if (rk !== "ArrowFunction" && rk !== "FunctionExpression") continue; + + const qualified = this._qualifiedNameForAssignmentTarget( + left, + bin, + relativePath, + ); + if (!qualified) continue; + this._emitAssignedFunction(qualified, right, relativePath); + } + + // 2. Object.assign(, { ... }) and + // Object.defineProperty(, "name", { value: fn }) + for (const call of sourceFile.getDescendantsOfKind( + ts.SyntaxKind.CallExpression, + )) { + const callee = call.getExpression(); + if (!callee || callee.getKindName() !== "PropertyAccessExpression") { + continue; + } + const objText = + callee.getExpression && callee.getExpression() + ? callee.getExpression().getText() + : null; + const member = callee.getName ? callee.getName() : null; + if (objText !== "Object") continue; + const args = call.getArguments(); + + if (member === "assign" && args.length >= 2) { + const target = this._receiverClassName(args[0]); + if (!target) continue; + const objLit = args[1]; + if (objLit.getKindName() !== "ObjectLiteralExpression") continue; + for (const prop of objLit.getProperties()) { + const pk = prop.getKindName(); + let propName = null; + let fnNode = null; + if (pk === "MethodDeclaration") { + propName = prop.getName(); + fnNode = prop; + } else if (pk === "PropertyAssignment") { + propName = prop.getName(); + const init = prop.getInitializer(); + const ik = init && init.getKindName(); + if (ik === "ArrowFunction" || ik === "FunctionExpression") { + fnNode = init; + } + } + if (propName && fnNode) { + this._emitAssignedFunction( + `${target}.${propName}`, + fnNode, + relativePath, + ); + } + } + } else if (member === "defineProperty" && args.length >= 3) { + const target = this._receiverClassName(args[0]); + const nameArg = args[1]; + const descriptor = args[2]; + if (!target) continue; + if ( + nameArg.getKindName() !== "StringLiteral" || + descriptor.getKindName() !== "ObjectLiteralExpression" + ) { + continue; + } + const propName = nameArg.getLiteralValue + ? nameArg.getLiteralValue() + : nameArg.getText().slice(1, -1); + for (const prop of descriptor.getProperties()) { + if (prop.getKindName() !== "PropertyAssignment") continue; + const dName = prop.getName(); + if (dName !== "value" && dName !== "get" && dName !== "set") continue; + const init = prop.getInitializer(); + const ik = init && init.getKindName(); + if (ik === "ArrowFunction" || ik === "FunctionExpression") { + this._emitAssignedFunction( + `${target}.${propName}`, + init, + relativePath, + ); + } + } + } + } + } + + /** + * Map an assignment target PropertyAccessExpression to a qualified + * "Class.member" name. + * Foo.prototype.bar -> "Foo.bar" + * this.bar (in fn F) -> "F.bar" + */ + _qualifiedNameForAssignmentTarget(left, binExpr, relativePath) { + const member = left.getName ? left.getName() : null; + if (!member) return null; + const obj = left.getExpression ? left.getExpression() : null; + if (!obj) return null; + const objKind = obj.getKindName(); + + if (objKind === "PropertyAccessExpression") { + // Foo.prototype.bar -> obj is `Foo.prototype` + if (obj.getName && obj.getName() === "prototype") { + const cls = obj.getExpression ? obj.getExpression().getText() : null; + if (cls) return `${cls}.${member}`; + } + return null; + } + + if (objKind === "ThisKeyword") { + // this.bar — name the enclosing function as the class. + const cls = this._enclosingFunctionName(binExpr); + if (cls) return `${cls}.${member}`; + } + return null; + } + + /** + * The name of the nearest enclosing named function declaration (used to + * label `this.method = fn` assignments inside constructor functions). + */ + _enclosingFunctionName(node) { + let cur = node.getParent && node.getParent(); + while (cur) { + const k = cur.getKindName(); + if (k === "FunctionDeclaration" && cur.getName && cur.getName()) { + return cur.getName(); + } + cur = cur.getParent && cur.getParent(); + } + return null; + } + + /** + * Resolve a `Object.assign`/`Object.defineProperty` first argument to a class + * name. Accepts `Foo.prototype` -> "Foo" and bare `Foo` -> "Foo". + */ + _receiverClassName(arg) { + if (!arg) return null; + const k = arg.getKindName(); + if (k === "PropertyAccessExpression") { + if (arg.getName && arg.getName() === "prototype") { + return arg.getExpression ? arg.getExpression().getText() : null; + } + return null; + } + if (k === "Identifier") { + return arg.getText(); + } + return null; + } + + /** + * Emit a function entry for an assigned method shape, keyed + * ":". + */ + _emitAssignedFunction(qualifiedName, fnNode, relativePath) { + const functionId = `${relativePath}:${qualifiedName}`; + if (this.functions[functionId]) return; + const code = fnNode.getFullText(); + const methodName = qualifiedName.includes(".") + ? qualifiedName.split(".").pop() + : qualifiedName; + const className = qualifiedName.includes(".") + ? qualifiedName.slice(0, qualifiedName.lastIndexOf(".")) + : null; + this.functions[functionId] = { + name: qualifiedName, + code: code, + isExported: false, + unitType: this.classifyFunction(methodName, code, className !== null, className), + startLine: fnNode.getStartLineNumber(), + endLine: fnNode.getEndLineNumber(), + className: className, + }; + // Pattern-A companion: emit the callGraph entry alongside the function so + // `len(callGraph) === len(functions)` holds. + this.callGraph[functionId] = this.extractCallsFromFunction( + fnNode, + relativePath, + ); + } + + /** + * Emit a synthetic unit when a file's top-level statements are dominated by + * a bare side-effect call (e.g. an Electron preload script that only calls + * contextBridge.exposeInMainWorld({...})). Without this, such files yield + * zero units even though they carry analysable behaviour. + */ + _extractTopLevelSideEffects(sourceFile, relativePath) { + // Only synthesise when the regular extractors produced nothing for this + // file — otherwise we'd duplicate real functions/methods. + const filePrefix = `${relativePath}:`; + const hasAny = Object.keys(this.functions).some((id) => + id.startsWith(filePrefix), + ); + if (hasAny) return; + + for (const statement of sourceFile.getStatements()) { + if (statement.getKindName() !== "ExpressionStatement") continue; + const expr = statement.getExpression(); + if (!expr || expr.getKindName() !== "CallExpression") continue; + + const callee = expr.getExpression(); + let label = "module"; + if (callee && callee.getKindName() === "PropertyAccessExpression") { + const obj = callee.getExpression + ? callee.getExpression().getText() + : ""; + const member = callee.getName ? callee.getName() : ""; + label = member ? `${obj}.${member}` : obj || "module"; + } else if (callee && callee.getKindName() === "Identifier") { + label = callee.getText(); + } + + const functionId = `${relativePath}:${label}`; + if (this.functions[functionId]) continue; + const code = statement.getFullText(); + this.functions[functionId] = { + name: label, + code: code, + isExported: false, + unitType: "module_level", + startLine: statement.getStartLineNumber(), + endLine: statement.getEndLineNumber(), + }; + // One synthetic unit per file is enough to make the file analysable. + return; + } + } + + /** + * HTTP verbs we recognise on a router/app/server object. Shared by Express, + * Fastify and Koa router DSLs. `use` is included to pick + * up middleware-mount callbacks; `route` covers `app.route('/x', handler)` + * style registrations. */ static EXPRESS_VERBS = new Set([ "get", @@ -355,6 +835,7 @@ class TypeScriptAnalyzer { "options", "head", "all", + "route", "use", ]); @@ -386,13 +867,15 @@ class TypeScriptAnalyzer { * receivers so generic `.get(...)` calls on caches / clients / query-builders * aren't misread as routes. * - * Accepted stems: app, router, routes, server, web, api, endpoints, controller. - * Codebases using single-word identifiers outside this list (e.g. `http`) will - * not be extracted; add the stem here if needed. + * Accepted stems: app, router, routes, server, web, api, endpoints, + * controller, plus framework names fastify and koa so + * `fastify.get(...)` / `koa.get(...)` registrations are recognised alongside + * Express. Codebases using single-word identifiers outside this list (e.g. + * `http`) will not be extracted; add the stem here if needed. */ - // Stems that strongly suggest an Express app/router object. + // Stems that strongly suggest an Express/Fastify/Koa app/router/server object. static EXPRESS_RECEIVER_STEMS = - "app|router|routes|server|web|api|endpoints|controller"; + "app|router|routes|server|web|api|endpoints|controller|fastify|koa"; _isPlausibleExpressReceiver(receiver) { if (!receiver) return false; @@ -667,6 +1150,11 @@ class TypeScriptAnalyzer { endLine: statement.getEndLineNumber(), exportType: "commonjs", }; + // Pattern-A companion. + this.callGraph[functionId] = this.extractCallsFromFunction( + right, + relativePath, + ); } } } @@ -688,11 +1176,15 @@ class TypeScriptAnalyzer { kindName === "PropertyAssignment" ) { let name, code; + // The AST node whose body holds the function's call expressions, used + // to emit the Pattern-A callGraph companion. null for re-exports. + let bodyNode = null; if (kindName === "MethodDeclaration") { // Pattern: { methodName() { ... } } name = property.getName(); code = property.getFullText(); + bodyNode = property; } else if (kindName === "ShorthandPropertyAssignment") { // Pattern: { methodName } - references a variable defined elsewhere name = property.getName(); @@ -701,6 +1193,9 @@ class TypeScriptAnalyzer { continue; } else if (kindName === "PropertyAssignment") { // Pattern: { methodName: () => { ... } } or { methodName: function() { ... } } + // Also re-export barrels: { foo: require('./x').foo } — the value is + // a function reference, not an inline function. We surface + // the property so the re-exported symbol is visible downstream. name = property.getName(); const initializer = property.getInitializer(); if ( @@ -709,8 +1204,11 @@ class TypeScriptAnalyzer { initializer.getKindName() === "FunctionExpression") ) { code = property.getFullText(); + bodyNode = initializer; + } else if (initializer && this._isReexportInitializer(initializer)) { + code = property.getFullText(); } else { - continue; // Not a function + continue; // Not a function or re-export } } @@ -727,6 +1225,14 @@ class TypeScriptAnalyzer { endLine: property.getEndLineNumber(), exportType: exportType, }; + // Pattern-A companion. Re-export references have no + // inline body; the Step-4 backstop fills them with []. + if (bodyNode) { + this.callGraph[functionId] = this.extractCallsFromFunction( + bodyNode, + relativePath, + ); + } } } } @@ -778,7 +1284,14 @@ class TypeScriptAnalyzer { for (const classDecl of sourceFile.getClasses()) { const className = classDecl.getName() || "AnonymousClass"; - for (const method of classDecl.getMethods()) { + // Mirror the inventory builder: include get/set accessors so + // every emitted function gets a callGraph companion (Pattern-A). + const classMembers = [ + ...classDecl.getMethods(), + ...classDecl.getGetAccessors(), + ...classDecl.getSetAccessors(), + ]; + for (const method of classMembers) { const methodName = method.getName(); const callerId = `${relativePath}:${className}.${methodName}`; this.callGraph[callerId] = this.extractCallsFromFunction( @@ -790,30 +1303,165 @@ class TypeScriptAnalyzer { } /** - * Extract function calls from within a function body + * Parameter names for a function-like node, used to populate the per-function + * `parameters` schema field. Returns [] when the node has no + * getParameters() accessor. + */ + _extractParameters(node) { + if (!node || !node.getParameters) return []; + try { + return node.getParameters().map((p) => p.getName()); + } catch { + return []; + } + } + + /** + * Normalise a call-expression callee to a simple identifier name, matching + * the C parser's `_extract_call_name` contract: + * plain(...) -> "plain" + * obj.method(...) -> "method" (trailing member) + * foo.bar().baz(...) -> "baz" (trailing member of the chain) + * obj['m'](...) -> null (dynamic / element access) + * (expr)(...) -> null + * Returns null when no stable identifier name can be derived (the call is + * then treated as dynamic/indirect). + */ + _normalizeCallName(calleeExpr) { + if (!calleeExpr) return null; + const kind = calleeExpr.getKindName(); + if (kind === "Identifier") { + return calleeExpr.getText(); + } + if (kind === "PropertyAccessExpression") { + // Trailing member name (e.g. `baz` from `foo.bar().baz`). + return calleeExpr.getName ? calleeExpr.getName() : null; + } + // ElementAccessExpression (obj['m']), ParenthesizedExpression, etc. have no + // stable identifier name — treat as dynamic. + return null; + } + + /** + * Extract function calls from within a function body. + * + * Each entry is { resolved, name, dynamic }: + * - `name` is the normalised callee identifier or null. + * - `dynamic` is true when the callee has no stable identifier name + * (element access, IIFE, etc.) — bucketed into indirect_calls. + * Identifier arguments passed to a call (callback arguments) are also + * recorded as edges so `addEventListener('click', handler)` / `setTimeout(cb)` + * / `arr.forEach(cb)` relationships are visible. */ extractCallsFromFunction(funcNode, currentFile) { const calls = []; - const callExpressions = funcNode - .getDescendantsOfKind(funcNode.getKind()) - .filter((n) => n.getKindName() === "CallExpression"); + const seen = new Set(); + const pushName = (name, dynamic) => { + const key = `${name}${dynamic ? 1 : 0}`; + if (seen.has(key)) return; + seen.add(key); + calls.push({ resolved: false, name: name, dynamic: dynamic }); + }; - // This is simplified - a full implementation would: - // 1. Resolve import/require statements - // 2. Track variable assignments - // 3. Resolve member expressions (obj.method()) - // 4. Handle dynamic calls + const callExpressions = funcNode.getDescendantsOfKind( + ts.SyntaxKind.CallExpression, + ); - // For now, just track that calls exist without full resolution for (const callExpr of callExpressions) { - calls.push({ - resolved: false, - name: callExpr.getExpression().getText(), - }); + const callee = callExpr.getExpression(); + const normalized = this._normalizeCallName(callee); + if (normalized) { + pushName(normalized, false); + } else { + // Dynamic / element-access callee — record the raw span trimmed to a + // single line so node names never become multiline blobs. + const raw = callee ? callee.getText().replace(/\s+/g, " ").trim() : ""; + pushName(raw, true); + } + + // Callback arguments: bare identifiers handed to the call become edges. + for (const arg of callExpr.getArguments()) { + if (arg.getKindName() === "Identifier") { + pushName(arg.getText(), false); + } + } } return calls; } + + /** + * Build the resolved bidirectional call graph and indirect-call buckets in + * the snake_case shape the Python pipeline consumes. Resolution is JS-native + * and conservative: + * same-file name match, else unique-name match across the repo — mirroring + * the contract the C/Ruby siblings satisfy, without lifting their code. + * + * Also stamps per-function `parameters` metadata so the schema contract is + * complete. + */ + _buildResolvedGraphs() { + // Index functions by file and by simple name for resolution. + const byFile = Object.create(null); + const byName = Object.create(null); + for (const funcId of Object.keys(this.functions)) { + const file = funcId.slice(0, funcId.lastIndexOf(":")); + (byFile[file] = byFile[file] || []).push(funcId); + const simple = (this.functions[funcId].name || "").split(".").pop(); + (byName[simple] = byName[simple] || []).push(funcId); + } + + const resolveName = (name, callerFile) => { + // 1. Same-file simple-name match. + for (const funcId of byFile[callerFile] || []) { + const fname = this.functions[funcId].name || ""; + if (fname === name || fname.endsWith("." + name)) return funcId; + } + // 2. Unique-name match across the repo. + const candidates = byName[name]; + if (candidates && candidates.length === 1) return candidates[0]; + return null; + }; + + for (const [callerId, edges] of Object.entries(this.callGraph)) { + const callerFile = callerId.slice(0, callerId.lastIndexOf(":")); + const resolvedTargets = []; + const indirect = []; + + for (const edge of edges) { + const name = edge && edge.name; + if (!name) continue; + if (edge.dynamic) { + if (!indirect.includes(name)) indirect.push(name); + continue; + } + const target = resolveName(name, callerFile); + if (target && target !== callerId) { + if (!resolvedTargets.includes(target)) resolvedTargets.push(target); + } else if (!target) { + // Unresolvable static name — bucket as indirect so it isn't lost. + if (!indirect.includes(name)) indirect.push(name); + } + } + + this.resolvedCallGraph[callerId] = resolvedTargets; + if (indirect.length > 0) this.indirectCalls[callerId] = indirect; + + for (const target of resolvedTargets) { + (this.reverseCallGraph[target] = this.reverseCallGraph[target] || []); + if (!this.reverseCallGraph[target].includes(callerId)) { + this.reverseCallGraph[target].push(callerId); + } + } + } + + // Ensure every function has a (possibly empty) resolved entry. + for (const funcId of Object.keys(this.functions)) { + if (!(funcId in this.resolvedCallGraph)) { + this.resolvedCallGraph[funcId] = []; + } + } + } } /** @@ -857,7 +1505,14 @@ function extractSingleFunction(filePath, functionRef) { for (const classDecl of sourceFile.getClasses()) { const classNameMatch = classDecl.getName(); if (classNameMatch === className) { - for (const method of classDecl.getMethods()) { + // getMethods() excludes get/set accessors, so search the + // accessor lists too when resolving a single member by name. + const classMembers = [ + ...classDecl.getMethods(), + ...classDecl.getGetAccessors(), + ...classDecl.getSetAccessors(), + ]; + for (const method of classMembers) { if (method.getName() === functionName) { foundFunction = { node: method, diff --git a/libs/openant-core/parsers/javascript/unit_generator.js b/libs/openant-core/parsers/javascript/unit_generator.js index 7b76219c..8fa262a9 100644 --- a/libs/openant-core/parsers/javascript/unit_generator.js +++ b/libs/openant-core/parsers/javascript/unit_generator.js @@ -196,9 +196,11 @@ class UnitGenerator { const files = new Set(); files.add(primaryFilePath); + // Separator is the FIRST colon (matches the dependency_resolver + // contract); functionName may itself contain colons. for (const dep of upstreamDependencies) { if (dep.id) { - const colonIndex = dep.id.lastIndexOf(':'); + const colonIndex = dep.id.indexOf(':'); if (colonIndex > 0) { files.add(dep.id.substring(0, colonIndex)); } @@ -207,7 +209,7 @@ class UnitGenerator { for (const caller of downstreamCallers) { if (caller.id) { - const colonIndex = caller.id.lastIndexOf(':'); + const colonIndex = caller.id.indexOf(':'); if (colonIndex > 0) { files.add(caller.id.substring(0, colonIndex)); } @@ -221,8 +223,12 @@ class UnitGenerator { * Create a single analysis unit */ _createUnit(functionId, funcData, routeHandlerMap) { - // Parse function ID: "relative/path/file.ts:functionName" - const colonIndex = functionId.lastIndexOf(':'); + // Parse function ID: "relative/path/file.ts:functionName". + // The separator is the FIRST colon: the functionName may itself contain + // colons (e.g. an Express route id "src/r.ts:express(GET:/items/:id)"). + // This matches the dependency_resolver contract (`funcId.split(':')[0]`) + // and recovers filePath/functionName correctly for multi-colon ids. + const colonIndex = functionId.indexOf(':'); const filePath = functionId.substring(0, colonIndex); const functionName = functionId.substring(colonIndex + 1); diff --git a/libs/openant-core/tests/parsers/javascript/test_call_graph_companion.py b/libs/openant-core/tests/parsers/javascript/test_call_graph_companion.py new file mode 100644 index 00000000..1e6a2411 --- /dev/null +++ b/libs/openant-core/tests/parsers/javascript/test_call_graph_companion.py @@ -0,0 +1,78 @@ +"""Tests for the call-graph/functions companion invariant. + +Every function the analyzer emits into `functions` must also have a key in +`callGraph` (Pattern-A emit-without-companion). + +Skips when Node.js or the parser's npm dependencies aren't installed. +""" +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + + +PARSERS_JS_DIR = Path(__file__).parent.parent.parent.parent / "parsers" / "javascript" +NODE_MODULES = PARSERS_JS_DIR / "node_modules" + +pytestmark = pytest.mark.skipif( + not shutil.which("node") or not NODE_MODULES.exists(), + reason="Node.js or JS parser npm dependencies not available", +) + + +def _analyze(repo_path, file_path): + cmd = ["node", str(PARSERS_JS_DIR / "typescript_analyzer.js"), str(repo_path), str(file_path)] + result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + assert result.returncode == 0, ( + f"analyzer failed:\nstdout={result.stdout}\nstderr={result.stderr}" + ) + return json.loads(result.stdout) + + +def _write(tmp_path, name, content, filename="file.js"): + repo = tmp_path / name + repo.mkdir(parents=True, exist_ok=True) + fp = repo / filename + fp.write_text(content) + return repo, fp + + +def test_module_exports_property_has_companion(tmp_path): + """module.exports.fn = function(){} must appear in callGraph too.""" + repo, fp = _write( + tmp_path, + "sb3_commonjs", + "module.exports.userSearch = function (req, res) { return doThing(); };\n" + "function doThing(){ return 1; }\n", + ) + out = _analyze(repo, fp) + funcs = set(out["functions"]) + graph = set(out["callGraph"]) + missing = funcs - graph + assert not missing, f"functions without callGraph companion: {missing}" + assert len(out["callGraph"]) == len(out["functions"]) + + +def test_all_emit_paths_have_companions(tmp_path): + """Object-literal exports, prototype assignments and class-expression + methods must each get a callGraph companion.""" + repo, fp = _write( + tmp_path, + "sb3_mixed", + """ +function Foo(){} +Foo.prototype.bar = function(){ return helper(); }; +function helper(){ return 1; } +module.exports = { util: () => doThing() }; +function doThing(){ return 2; } +const Svc = class { run(){ return helper(); } }; +""", + ) + out = _analyze(repo, fp) + funcs = set(out["functions"]) + graph = set(out["callGraph"]) + missing = funcs - graph + assert not missing, f"functions without callGraph companion: {missing}" + assert len(out["callGraph"]) == len(out["functions"]) diff --git a/libs/openant-core/tests/parsers/javascript/test_call_graph_extraction.py b/libs/openant-core/tests/parsers/javascript/test_call_graph_extraction.py new file mode 100644 index 00000000..62e667f0 --- /dev/null +++ b/libs/openant-core/tests/parsers/javascript/test_call_graph_extraction.py @@ -0,0 +1,153 @@ +"""Tests for the JS analyzer call-graph extraction. + +These run the typescript_analyzer.js Node script as a subprocess on inline +fixtures (mirroring test_express_route_handlers.py) and assert on the emitted +`functions` / `callGraph`. + +Skips when Node.js or the parser's npm dependencies aren't installed. +""" +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + + +PARSERS_JS_DIR = Path(__file__).parent.parent.parent.parent / "parsers" / "javascript" +NODE_MODULES = PARSERS_JS_DIR / "node_modules" + +pytestmark = pytest.mark.skipif( + not shutil.which("node") or not NODE_MODULES.exists(), + reason="Node.js or JS parser npm dependencies not available", +) + + +def _analyze(repo_path, file_path): + cmd = ["node", str(PARSERS_JS_DIR / "typescript_analyzer.js"), str(repo_path), str(file_path)] + result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + assert result.returncode == 0, ( + f"analyzer failed:\nstdout={result.stdout}\nstderr={result.stderr}" + ) + return json.loads(result.stdout) + + +def _write(tmp_path, name, content, filename="file.js"): + repo = tmp_path / name + repo.mkdir(parents=True, exist_ok=True) + fp = repo / filename + fp.write_text(content) + return repo, fp + + +def _names(edges): + """Collect the textual call names from a callGraph edge list.""" + out = [] + for e in edges: + if isinstance(e, dict): + out.append(e.get("name") or e.get("functionId")) + else: + out.append(e) + return out + + +# --- foundational call-edge extraction ------------------------------ + +def test_call_graph_records_callee(tmp_path): + """`function a(){b();} function b(){}` -> callGraph['file.js:a'] names b. + + Regression for the `getDescendantsOfKind(funcNode.getKind())` bug that + made every out-edge list empty. + """ + repo, fp = _write( + tmp_path, + "sb1", + "function a(){ b(); }\nfunction b(){ return 1; }\n", + ) + out = _analyze(repo, fp) + edges = out["callGraph"]["file.js:a"] + assert "b" in _names(edges), f"expected a->b edge; callGraph['file.js:a']={edges}" + + +def test_arrow_function_call_edges(tmp_path): + """Arrow function bodies must also yield out-edges.""" + repo, fp = _write( + tmp_path, + "sb1_arrow", + "function helper(){ return 1; }\nconst run = () => { helper(); };\n", + ) + out = _analyze(repo, fp) + edges = out["callGraph"]["file.js:run"] + assert "helper" in _names(edges), f"expected run->helper edge; got {edges}" + + +# --- call-edge content ---------------------------------------------- + +def test_callback_argument_edges(tmp_path): + """`addEventListener('click', handler)` / `setTimeout(cb)` must record the + callback identifier as a call edge.""" + repo, fp = _write( + tmp_path, + "sb4_cb", + """ +function handler(){ return 1; } +function register(){ + el.addEventListener('click', handler); + setTimeout(handler, 10); + [1,2].forEach(handler); +} +""", + ) + out = _analyze(repo, fp) + edges = out["callGraph"]["file.js:register"] + names = _names(edges) + assert "handler" in names, ( + f"callback identifier `handler` must be recorded as a call edge; got {names}" + ) + + +def test_call_name_normalized_to_identifier(tmp_path): + """Chained / element-access callees must normalize to an identifier name, + not a multiline raw span.""" + repo, fp = _write( + tmp_path, + "sb4_norm", + """ +function build(){ + return foo + .bar() + .baz(); +} +""", + ) + out = _analyze(repo, fp) + edges = out["callGraph"]["file.js:build"] + for name in _names(edges): + assert name is not None + assert "\n" not in name, f"call name must not be a multiline blob: {name!r}" + # A normalized name is a bare identifier (the trailing member name). + assert name.replace("$", "_").replace("_", "a").isalnum() or name == "", ( + f"call name should be a simple identifier, got {name!r}" + ) + + +def test_unresolved_calls_bucketed_into_indirect_calls(tmp_path): + """Dynamic / unresolvable calls populate `indirect_calls` rather than + silently vanishing.""" + repo, fp = _write( + tmp_path, + "sb4_indirect", + """ +function dispatch(cb){ + cb(); + obj['method'](); +} +""", + ) + out = _analyze(repo, fp) + assert "indirect_calls" in out, "analyzer output must expose indirect_calls" + # The unresolved callee(s) of dispatch should appear under indirect_calls. + entry = out["indirect_calls"].get("file.js:dispatch", []) + assert len(entry) >= 1, ( + f"expected dispatch's dynamic call(s) bucketed into indirect_calls; got {out['indirect_calls']}" + ) diff --git a/libs/openant-core/tests/parsers/javascript/test_express_route_handlers.py b/libs/openant-core/tests/parsers/javascript/test_express_route_handlers.py index 804e2078..8e8f7cbb 100644 --- a/libs/openant-core/tests/parsers/javascript/test_express_route_handlers.py +++ b/libs/openant-core/tests/parsers/javascript/test_express_route_handlers.py @@ -398,3 +398,68 @@ def test_named_handler_no_anonymous_unit(tmp_path): assert any( f.get("name") == "namedHandler" for f in out["functions"].values() ) + + +# --- route REGISTRATION broadened to Fastify/Koa -------- + +def test_fastify_inline_handler_synthesised(tmp_path): + """fastify.get('/users', async (request, reply) => {...}) must synthesise a + route_handler unit (is_entry_point) even though the receiver is `fastify`, + not an Express app/router. Express-only registration would skip it.""" + file_path = _write_fixture( + tmp_path, + "fastify_route", + """ +const fastify = require('fastify')(); +fastify.get('/users', async (request, reply) => { + return { users: [] }; +}); +""", + ) + repo = file_path.parent + out = _analyze(repo, file_path) + express_funcs = {k: v for k, v in out["functions"].items() if "express(" in k} + assert len(express_funcs) == 1, ( + f"expected 1 synthesised Fastify handler, got {list(express_funcs)}" + ) + fid, fdata = next(iter(express_funcs.items())) + assert fdata["unitType"] == "route_handler" + assert fdata["isEntryPoint"] is True + meta = fdata["routeMetadata"] + assert meta["http_method"] == "GET" + assert meta["http_path"] == "/users" + + # End-to-end through unit_generator: is_entry_point must survive. + analyzer_path = tmp_path / "analyzer.json" + analyzer_path.write_text(json.dumps(out)) + dataset_path = tmp_path / "dataset.json" + dataset = _generate_units(analyzer_path, dataset_path) + handler_unit = next(u for u in dataset["units"] if u["id"] == fid) + assert handler_unit["unit_type"] == "route_handler" + assert handler_unit["is_entry_point"] is True + + +def test_koa_inline_handler_synthesised(tmp_path): + """Bare `koa.get('/x', ctx => {})` must also synthesise a route handler. + (router.get already works because `router` is an Express stem; this checks + the additional `koa` receiver stem.)""" + file_path = _write_fixture( + tmp_path, + "koa_route", + """ +const Koa = require('koa'); +const koa = new Koa(); +koa.get('/x', ctx => { ctx.body = 'hi'; }); +""", + ) + repo = file_path.parent + out = _analyze(repo, file_path) + express_funcs = {k: v for k, v in out["functions"].items() if "express(" in k} + assert len(express_funcs) == 1, ( + f"expected 1 synthesised Koa handler, got {list(express_funcs)}" + ) + fid, fdata = next(iter(express_funcs.items())) + assert fdata["unitType"] == "route_handler" + assert fdata["isEntryPoint"] is True + assert fdata["routeMetadata"]["http_method"] == "GET" + assert fdata["routeMetadata"]["http_path"] == "/x" diff --git a/libs/openant-core/tests/parsers/javascript/test_framework_classification.py b/libs/openant-core/tests/parsers/javascript/test_framework_classification.py new file mode 100644 index 00000000..e37c740a --- /dev/null +++ b/libs/openant-core/tests/parsers/javascript/test_framework_classification.py @@ -0,0 +1,82 @@ +"""Tests for framework route-handler classification. + +Fastify `(request, reply)` handlers must classify as route_handlers, and React +components that happen to take `(request, response)` must NOT be misclassified as +routes. + +Skips when Node.js or the parser's npm dependencies aren't installed. +""" +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + + +PARSERS_JS_DIR = Path(__file__).parent.parent.parent.parent / "parsers" / "javascript" +NODE_MODULES = PARSERS_JS_DIR / "node_modules" + +pytestmark = pytest.mark.skipif( + not shutil.which("node") or not NODE_MODULES.exists(), + reason="Node.js or JS parser npm dependencies not available", +) + + +def _analyze(repo_path, file_path): + cmd = ["node", str(PARSERS_JS_DIR / "typescript_analyzer.js"), str(repo_path), str(file_path)] + result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + assert result.returncode == 0, ( + f"analyzer failed:\nstdout={result.stdout}\nstderr={result.stderr}" + ) + return json.loads(result.stdout) + + +def _write(tmp_path, name, content, filename="file.js"): + repo = tmp_path / name + repo.mkdir(parents=True, exist_ok=True) + fp = repo / filename + fp.write_text(content) + return repo, fp + + +def test_fastify_request_reply_is_route_handler(tmp_path): + repo, fp = _write( + tmp_path, + "sb6_fastify", + "function fastifyRoute(request, reply) { reply.send({ ok: true }); }\n", + ) + out = _analyze(repo, fp) + fn = out["functions"]["file.js:fastifyRoute"] + assert fn["unitType"] == "route_handler", ( + f"Fastify (request, reply) handler must classify as route_handler; got {fn['unitType']}" + ) + + +def test_koa_ctx_is_route_handler(tmp_path): + repo, fp = _write( + tmp_path, + "sb6_koa", + "function koaRoute(ctx) { ctx.body = 'ok'; }\n", + ) + out = _analyze(repo, fp) + fn = out["functions"]["file.js:koaRoute"] + assert fn["unitType"] == "route_handler", ( + f"Koa (ctx) handler must classify as route_handler; got {fn['unitType']}" + ) + + +def test_react_component_not_misclassified(tmp_path): + """A function named like a component taking (request, response) must not be + treated as an Express route just because of the parameter names.""" + repo, fp = _write( + tmp_path, + "sb6_react", + "function MyComponent(request, response) { return null; }\n", + filename="file.jsx", + ) + out = _analyze(repo, fp) + fn = out["functions"]["file.jsx:MyComponent"] + assert fn["unitType"] != "route_handler", ( + f"React component taking (request, response) must NOT be a route_handler; got {fn['unitType']}" + ) diff --git a/libs/openant-core/tests/parsers/javascript/test_function_inventory_gaps.py b/libs/openant-core/tests/parsers/javascript/test_function_inventory_gaps.py new file mode 100644 index 00000000..6fab4389 --- /dev/null +++ b/libs/openant-core/tests/parsers/javascript/test_function_inventory_gaps.py @@ -0,0 +1,210 @@ +"""Tests for AST-shape inventory gaps in the JS analyzer. + +Each test feeds an inline fixture with a particular function-defining AST +shape and asserts the function appears in the analyzer's `functions` map. + +Skips when Node.js or the parser's npm dependencies aren't installed. +""" +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + + +PARSERS_JS_DIR = Path(__file__).parent.parent.parent.parent / "parsers" / "javascript" +NODE_MODULES = PARSERS_JS_DIR / "node_modules" + +pytestmark = pytest.mark.skipif( + not shutil.which("node") or not NODE_MODULES.exists(), + reason="Node.js or JS parser npm dependencies not available", +) + + +def _analyze(repo_path, file_path): + cmd = ["node", str(PARSERS_JS_DIR / "typescript_analyzer.js"), str(repo_path), str(file_path)] + result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + assert result.returncode == 0, ( + f"analyzer failed:\nstdout={result.stdout}\nstderr={result.stderr}" + ) + return json.loads(result.stdout) + + +def _write(tmp_path, name, content, filename="file.js"): + repo = tmp_path / name + repo.mkdir(parents=True, exist_ok=True) + fp = repo / filename + fp.write_text(content) + return repo, fp + + +# --- ClassExpression (module.exports = class {}) ------------------ + +def test_class_expression_methods_extracted(tmp_path): + repo, fp = _write( + tmp_path, + "b073", + "module.exports = class Service {\n doWork() { return 1; }\n};\n", + ) + out = _analyze(repo, fp) + keys = list(out["functions"]) + assert any(k.endswith(":Service.doWork") for k in keys), ( + f"ClassExpression method `doWork` must be extracted; got {keys}" + ) + + +# --- anon export default fn, prototype assign, this.method, Object.assign --- + +def test_anonymous_export_default_function(tmp_path): + repo, fp = _write( + tmp_path, + "b088_default", + "export default function(){ return secretHelper(); }\nfunction secretHelper(){ return 1; }\n", + ) + out = _analyze(repo, fp) + keys = list(out["functions"]) + assert any("default" in k for k in keys), ( + f"anonymous `export default function(){{}}` must be extracted; got {keys}" + ) + + +def test_prototype_method_assignment(tmp_path): + repo, fp = _write( + tmp_path, + "b088_proto", + "function Foo(){}\nFoo.prototype.bar = function(){ return 2; };\n", + ) + out = _analyze(repo, fp) + keys = list(out["functions"]) + assert any(k.endswith(":Foo.bar") for k in keys), ( + f"`Foo.prototype.bar = fn` must be extracted as Foo.bar; got {keys}" + ) + + +def test_object_assign_prototype(tmp_path): + repo, fp = _write( + tmp_path, + "b088_assign", + "function Foo(){}\nObject.assign(Foo.prototype, {\n greet(){ return 'hi'; },\n wave: function(){ return 'wave'; },\n});\n", + ) + out = _analyze(repo, fp) + keys = list(out["functions"]) + assert any(k.endswith(":Foo.greet") for k in keys), ( + f"`Object.assign(Foo.prototype, {{greet(){{}}}})` must extract greet; got {keys}" + ) + assert any(k.endswith(":Foo.wave") for k in keys), ( + f"`Object.assign(Foo.prototype, {{wave: fn}})` must extract wave; got {keys}" + ) + + +# --- this.method = fn inside a constructor function ---------------- + +def test_this_method_in_constructor(tmp_path): + repo, fp = _write( + tmp_path, + "b127", + "function Ctor(){\n this.handleLogin = function(){ return 3; };\n}\n", + ) + out = _analyze(repo, fp) + keys = list(out["functions"]) + assert any(k.endswith(":Ctor.handleLogin") for k in keys), ( + f"`this.handleLogin = fn` in a constructor must be extracted; got {keys}" + ) + + +# --- Object.defineProperty(X.prototype, "n", {value: fn}) ---------- + +def test_define_property_value_function(tmp_path): + repo, fp = _write( + tmp_path, + "b092", + 'function X(){}\nObject.defineProperty(X.prototype, "n", { value: function(){ return 1; } });\n', + ) + out = _analyze(repo, fp) + keys = list(out["functions"]) + assert any(k.endswith(":X.n") for k in keys), ( + f"`Object.defineProperty(X.prototype, 'n', {{value: fn}})` must extract X.n; got {keys}" + ) + + +# --- top-level side-effect call ----------------------- + +def test_top_level_side_effect_call_yields_unit(tmp_path): + repo, fp = _write( + tmp_path, + "m006", + "contextBridge.exposeInMainWorld('api', { ping: () => 1 });\n", + ) + out = _analyze(repo, fp) + assert len(out["functions"]) >= 1, ( + f"a file with only a top-level side-effect call must emit >=1 unit; got {list(out['functions'])}" + ) + + +# --- HOC const initializer (memo/forwardRef/styled) --- + +def test_hoc_const_initializer(tmp_path): + repo, fp = _write( + tmp_path, + "m008", + "const Card = memo(() => { return null; });\nconst Wrapped = forwardRef((props, ref) => { return null; });\n", + filename="file.jsx", + ) + out = _analyze(repo, fp) + keys = list(out["functions"]) + assert any(k.endswith(":Card") for k in keys), ( + f"`const Card = memo(() => {{}})` must be extracted; got {keys}" + ) + assert any(k.endswith(":Wrapped") for k in keys), ( + f"`const Wrapped = forwardRef(...)` must be extracted; got {keys}" + ) + + +# --- re-export barrel object literal ------------------------------- + +def test_reexport_barrel_property(tmp_path): + repo, fp = _write( + tmp_path, + "b109", + "module.exports = { foo: require('./x').foo };\n", + ) + out = _analyze(repo, fp) + keys = list(out["functions"]) + assert any(k.endswith(":exports.foo") or k.endswith(":foo") for k in keys), ( + f"re-export barrel `{{foo: require('./x').foo}}` must surface foo; got {keys}" + ) + + +# --- class accessors (getters/setters) dropped by getMethods() ----- + +def test_class_accessors_extracted_with_companion(tmp_path): + """`get x(){}` / `set x(v){}` are excluded by ts-morph getMethods(), so they + must be iterated via getGetAccessors()/getSetAccessors(). The accessor must + appear in `functions` AND have a callGraph companion (Pattern-A).""" + repo, fp = _write( + tmp_path, + "b126", + "class Account {\n" + " get balance(){ return 1; }\n" + " set balance(v){ this._v = v; }\n" + " normalMethod(){ return 2; }\n" + "}\n", + ) + out = _analyze(repo, fp) + funcs = list(out["functions"]) + graph = set(out["callGraph"]) + # The accessor `balance` must be emitted as a function. + accessor_keys = [k for k in funcs if k.endswith(":Account.balance")] + assert accessor_keys, ( + f"class accessor `get/set balance` must be extracted as Account.balance; got {funcs}" + ) + # And it must have a callGraph companion (no Pattern-A asymmetry). + for k in accessor_keys: + assert k in graph, ( + f"accessor {k} present in functions but missing callGraph companion; graph={sorted(graph)}" + ) + # Sanity: the plain method is still emitted. + assert any(k.endswith(":Account.normalMethod") for k in funcs), ( + f"plain method normalMethod must still be extracted; got {funcs}" + ) diff --git a/libs/openant-core/tests/parsers/javascript/test_schema_contract.py b/libs/openant-core/tests/parsers/javascript/test_schema_contract.py new file mode 100644 index 00000000..06d0fa7b --- /dev/null +++ b/libs/openant-core/tests/parsers/javascript/test_schema_contract.py @@ -0,0 +1,117 @@ +"""Tests for the JS analyzer schema contract. + +The analyzer must emit the snake_case `call_graph` / `reverse_call_graph` +(resolved id lists), `repository`, and per-function `parameters` — the same +contract the C/Python/Ruby sibling parsers satisfy. A pipeline-level test +confirms a non-entry reachable function survives the reachability filter. + +Skips when Node.js or the parser's npm dependencies aren't installed. +""" +import json +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + + +PARSERS_JS_DIR = Path(__file__).parent.parent.parent.parent / "parsers" / "javascript" +CORE_ROOT = Path(__file__).parent.parent.parent.parent +NODE_MODULES = PARSERS_JS_DIR / "node_modules" + +pytestmark = pytest.mark.skipif( + not shutil.which("node") or not NODE_MODULES.exists(), + reason="Node.js or JS parser npm dependencies not available", +) + + +def _analyze(repo_path, file_path): + cmd = ["node", str(PARSERS_JS_DIR / "typescript_analyzer.js"), str(repo_path), str(file_path)] + result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + assert result.returncode == 0, ( + f"analyzer failed:\nstdout={result.stdout}\nstderr={result.stderr}" + ) + return json.loads(result.stdout) + + +def _write(tmp_path, name, content, filename="file.js"): + repo = tmp_path / name + repo.mkdir(parents=True, exist_ok=True) + fp = repo / filename + fp.write_text(content) + return repo, fp + + +def test_analyzer_emits_snake_case_graphs(tmp_path): + repo, fp = _write( + tmp_path, + "sb5_graphs", + "function a(){ b(); }\nfunction b(){ return 1; }\n", + ) + out = _analyze(repo, fp) + assert "call_graph" in out, "analyzer must emit snake_case call_graph" + assert "reverse_call_graph" in out, "analyzer must emit reverse_call_graph" + assert out["call_graph"]["file.js:a"] == ["file.js:b"], ( + f"call_graph must list resolved callee ids; got {out['call_graph']}" + ) + assert out["reverse_call_graph"].get("file.js:b") == ["file.js:a"], ( + f"reverse_call_graph must list resolved caller ids; got {out['reverse_call_graph']}" + ) + + +def test_analyzer_emits_repository(tmp_path): + repo, fp = _write(tmp_path, "sb5_repo", "function a(){ return 1; }\n") + out = _analyze(repo, fp) + assert out.get("repository"), "analyzer must emit a repository path" + + +def test_analyzer_emits_parameters(tmp_path): + repo, fp = _write( + tmp_path, + "sb5_params", + "function add(x, y){ return x + y; }\n", + ) + out = _analyze(repo, fp) + fn = out["functions"]["file.js:add"] + assert "parameters" in fn, "each function must carry a parameters list" + assert fn["parameters"] == ["x", "y"], ( + f"parameters must list the parameter names; got {fn.get('parameters')}" + ) + + +def test_reachability_keeps_non_entry_callee(tmp_path): + """Pipeline-level: a function reachable only as a callee of an entry point + must survive the reachability filter, which depends on the analyzer's + reverse_call_graph being populated.""" + repo, fp = _write( + tmp_path, + "sb5_reach", + """ +const express = require('express'); +const app = express(); +function helper(){ return 1; } +app.get('/x', (req, res) => { res.json(helper()); }); +module.exports = app; +""", + ) + output_dir = tmp_path / "out" + output_dir.mkdir() + + parser_script = PARSERS_JS_DIR / "test_pipeline.py" + cmd = [ + sys.executable, str(parser_script), + str(repo), + "--output", str(output_dir), + "--processing-level", "reachable", + ] + result = subprocess.run(cmd, capture_output=True, text=True, timeout=120, cwd=str(CORE_ROOT)) + assert result.returncode == 0, ( + f"pipeline failed:\nstdout={result.stdout}\nstderr={result.stderr}" + ) + + dataset = json.loads((output_dir / "dataset.json").read_text()) + ids = {u["id"] for u in dataset["units"]} + assert any(uid.endswith(":helper") for uid in ids), ( + f"non-entry callee `helper` must survive reachability; surviving ids={ids}" + ) diff --git a/libs/openant-core/tests/parsers/javascript/test_unit_generator_function_id.py b/libs/openant-core/tests/parsers/javascript/test_unit_generator_function_id.py new file mode 100644 index 00000000..c035e18e --- /dev/null +++ b/libs/openant-core/tests/parsers/javascript/test_unit_generator_function_id.py @@ -0,0 +1,106 @@ +"""Tests for unit_generator.js functionId parsing. + +A functionId has the shape ":" where the SEPARATOR is +the FIRST colon (this is the contract the dependency_resolver enforces with +`funcId.split(':')[0]`). The functionName itself may contain colons, e.g. an +Express route id like "src/r.ts:express(GET:/items/:id)". + +unit_generator.js previously split on the LAST colon (`lastIndexOf(':')`), which +mangled both filePath and functionName for any multi-colon id. These tests drive +UnitGenerator.generateUnits via a node subprocess on a synthetic analyzer output +whose `functions` dict carries a multi-colon id, and assert the recovered +file_path is correct. + +Skips when Node.js or the parser's npm dependencies aren't installed. +""" +import json +import shutil +import subprocess +import textwrap +from pathlib import Path + +import pytest + + +PARSERS_JS_DIR = Path(__file__).parent.parent.parent.parent / "parsers" / "javascript" +NODE_MODULES = PARSERS_JS_DIR / "node_modules" + +pytestmark = pytest.mark.skipif( + not shutil.which("node") or not NODE_MODULES.exists(), + reason="Node.js or JS parser npm dependencies not available", +) + + +def _generate_units(tmp_path, analyzer_output): + """Run UnitGenerator.generateUnits(analyzer_output) via node and return the result dict.""" + out_json = tmp_path / "analyzer_output.json" + out_json.write_text(json.dumps(analyzer_output)) + driver = tmp_path / "driver.js" + driver.write_text( + textwrap.dedent( + f""" + const fs = require('fs'); + const {{ UnitGenerator }} = require({json.dumps(str(PARSERS_JS_DIR / "unit_generator.js"))}); + const analyzerOutput = JSON.parse(fs.readFileSync({json.dumps(str(out_json))}, 'utf8')); + const gen = new UnitGenerator('/repo', 'testds', {{ maxDepth: 1 }}); + const result = gen.generateUnits(analyzerOutput, null); + process.stdout.write(JSON.stringify(result)); + """ + ) + ) + proc = subprocess.run( + ["node", str(driver)], capture_output=True, text=True, timeout=30 + ) + assert proc.returncode == 0, f"driver failed:\nstdout={proc.stdout}\nstderr={proc.stderr}" + return json.loads(proc.stdout) + + +def test_multi_colon_function_id_recovers_file_path(tmp_path): + """A multi-colon Express functionId must recover the true file path + (everything before the FIRST colon), not be mangled by last-colon split. + + Regression guard: unit_generator.js used lastIndexOf(':'), + so "src/r.ts:express(GET:/items/:id)" produced file_path + "src/r.ts:express(GET:/items/" instead of "src/r.ts". + """ + func_id = "src/r.ts:express(GET:/items/:id)" + analyzer_output = { + "functions": { + func_id: { + "name": "express(GET:/items/:id)", + "code": "function(req,res){ return res.send('ok'); }", + "startLine": 42, + "endLine": 44, + } + }, + "classes": {}, + "callGraph": {}, + } + result = _generate_units(tmp_path, analyzer_output) + units = result["units"] + assert len(units) == 1, f"expected 1 unit, got {len(units)}" + file_path = units[0]["code"]["primary_origin"]["file_path"] + assert file_path == "src/r.ts", ( + f"multi-colon id file_path must be 'src/r.ts' (first-colon split), " + f"got {file_path!r}" + ) + + +def test_single_colon_function_id_still_parses(tmp_path): + """The common single-colon id must still parse correctly (no regression).""" + func_id = "src/util.ts:helper" + analyzer_output = { + "functions": { + func_id: { + "name": "helper", + "code": "function helper(){ return 1; }", + "startLine": 1, + "endLine": 1, + } + }, + "classes": {}, + "callGraph": {}, + } + result = _generate_units(tmp_path, analyzer_output) + file_path = result["units"][0]["code"]["primary_origin"]["file_path"] + assert file_path == "src/util.ts", f"single-colon file_path wrong: {file_path!r}"