diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index index c8f062565..5e7b034ca 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index @@ -128,6 +128,10 @@ A = { # introducesDeclarationLinkHash is the method — two hops, where TypeScript has boundFunctionLinkHash in one boundNames=dict(file='all-javascript-variables.csv', name='name', only=lambda r: r.get('initializerKind') == 'FUNCTION', via=dict(key='initializerExpressionLinkHash', file='all-javascript-expressions.csv', id='jsExpressionUniqueHash', method='introducesDeclarationLinkHash')), + # `exports.getUser = function …`, `obj.x = () => …`, `{ all: page => … }`: the function is named for the property it + # is the value of, the way the language's own name inference does it, and is owned by the object's variable + memberNames=dict(file='all-javascript-expressions.csv', id='jsExpressionUniqueHash', method='introducesDeclarationLinkHash', + vars='all-javascript-variables.csv'), skipped='skipped-javascript-files.csv'), 'csharp': dict( modules=dict(file='all-csharp-modules.csv', id='csModuleUniqueHash', filePath='filePath'), @@ -254,6 +258,49 @@ if A['boundNames']: else: for r in rows(b['file']): if r.get(b['method']): bound.setdefault(r[b['method']], r[b['name']]) +# a function that is the value of a property takes the property's name: `recv.key = function …` (an ASSIGNMENT whose +# target is a non-computed property access) and `{ key: () => … }` (an OBJECT_LITERAL's PROPERTY_VALUE, keyed by the +# PROPERTY_KEY sibling at the same childIndex). The owner is the receiver as written (`helpers`, `Foo` for +# `Foo.prototype`), none for `exports` / `module.exports` / `this`; for a literal, the variable it initialises or the +# key chain of the literal it is nested in. A named function expression keeps its own name: only placeholders move. +bound_owner = {} +if A.get('memberNames'): + mn = A['memberNames']; ex = {}; kids = collections.defaultdict(list) + for r in rows(mn['file']): + ex[r[mn['id']]] = r; kids[r.get('parentExpressionLinkHash', '')].append(r) + lit_var = {r['initializerExpressionLinkHash']: r['name'] for r in rows(mn['vars']) if r.get('initializerExpressionLinkHash') and r.get('name')} + DOTTED = re.compile(r'^[A-Za-z_$][\w$]*(\.[A-Za-z_$][\w$]*)*$') + def key_of(parent, role, idx): + for k in kids.get(parent, ()): + if k.get('edgeRole') == role and (idx is None or k.get('childIndex') == idx): + return k + def literal_owner(lit, depth=0): + if lit.get(mn['id']) in lit_var: return lit_var[lit[mn['id']]] + p = ex.get(lit.get('parentExpressionLinkHash', '')) + if depth > 8 or not p or lit.get('edgeRole') != 'PROPERTY_VALUE' or p.get('expressionKind') != 'OBJECT_LITERAL': return None + k = key_of(p[mn['id']], 'PROPERTY_KEY', lit.get('childIndex')) + if not k or k.get('isComputedName') == 'true' or not k.get('name'): return None + o = literal_owner(p, depth + 1) + return f"{o}.{k['name']}" if o else k['name'] + for r in ex.values(): + m_ = r.get(mn['method']) + if not m_ or m_ in bound or r.get('expressionKind') != 'FUNCTION_EXPRESSION': continue + p = ex.get(r.get('parentExpressionLinkHash', '')) + if not p: continue + role = r.get('edgeRole'); name = owner = None + if role == 'ASSIGNMENT_VALUE' and p.get('expressionKind') == 'ASSIGNMENT' and p.get('operatorString') == '=': + t = key_of(p[mn['id']], 'ASSIGNMENT_TARGET', None) + if t and t.get('expressionKind') == 'PROPERTY_ACCESS' and t.get('isComputedName') != 'true' and t.get('name'): + name = t['name']; recv = (t.get('text') or '')[:-(len(name) + 1)] + if recv.endswith('.prototype'): recv = recv[:-len('.prototype')] + if DOTTED.match(recv) and recv not in ('exports', 'module.exports', 'this', 'module'): owner = recv + elif role == 'PROPERTY_VALUE' and p.get('expressionKind') == 'OBJECT_LITERAL': + k = key_of(p[mn['id']], 'PROPERTY_KEY', r.get('childIndex')) + if k and k.get('isComputedName') != 'true' and k.get('name'): + name = k['name']; owner = literal_owner(p) + if name: + bound[m_] = name + if owner: bound_owner[m_] = owner # Java: a method declared in an enum CONSTANT's body (`Initial { boolean process(...) {…} }`) is registered per constant with the # same qualified_name and the enum as owner — 23 `HtmlTreeBuilderState.process` rows in the JVM parser. The constant's span names it. econst = collections.defaultdict(list) @@ -270,7 +317,7 @@ bound_at = set() # (file, line, name) of a v for m in c.execute("SELECT id, name, qualified_name, signature, kind, owner_type_id, owner_qualified_name, file_path, start_line, end_line FROM methods WHERE provenance='client'"): name = m['name'] or '' if name.startswith('<') and m['id'] in bound: name = bound[m['id']]; bound_at.add((rel(m['file_path']), m['start_line'], name)) - od = tdisplay(m['owner_type_id']) if m['owner_type_id'] else None + od = tdisplay(m['owner_type_id']) if m['owner_type_id'] else bound_owner.get(m['id']) if m['kind'] == 'ENUM_CONSTANT_METHOD' and od: k_ = enum_constant_of(rel(m['file_path']), m['start_line'] or 0) if k_: od = f"{od}.{k_}" diff --git a/tests/cases/javascript/member-assigned-function-names/api.js b/tests/cases/javascript/member-assigned-function-names/api.js new file mode 100644 index 000000000..d4045f7c4 --- /dev/null +++ b/tests/cases/javascript/member-assigned-function-names/api.js @@ -0,0 +1,46 @@ +// exports.x = function / arrow: CommonJS controller shape +exports.getUser = function (req, res) { + return lookup(req.id); +}; +exports.postUser = (req, res) => { + return lookup(req.body.id); +}; +module.exports.slugify = function (s) { + return s.toLowerCase(); +}; + +function lookup(id) { + return id; +} + +// an object literal key bound to an arrow or a function expression +const Articles = { + all: page => request('/articles', page), + get: function (slug) { + return request('/articles/' + slug); + }, + // CONTROL: method shorthand already keeps its name + del(slug) { + return request('/articles/' + slug, 'DELETE'); + }, +}; + +// obj.x = function +const helpers = {}; +helpers.titleCase = function (s) { + return s.toUpperCase(); +}; + +// CONTROL: a named function expression keeps its OWN name, not the key's +exports.alias = function realName(s) { + return s; +}; + +// CONTROL: an arrow passed as an argument has no name to take +[1, 2].map(n => lookup(n)); + +function request(url, arg) { + return url + arg; +} + +module.exports.Articles = Articles; diff --git a/tests/cases/javascript/member-assigned-function-names/case.json b/tests/cases/javascript/member-assigned-function-names/case.json new file mode 100644 index 000000000..5d7046ed1 --- /dev/null +++ b/tests/cases/javascript/member-assigned-function-names/case.json @@ -0,0 +1,48 @@ +{ + "lang": "javascript", + "src": ".", + "checks": [ + { + "why": "a function assigned to exports.x is named x: `impact getUser` answered `type getUser (not declared here)` because the function was ``", + "run": ["impact", "getUser"], + "want": ["change: getUser [method]", "[resolved] run use.js:4"], + "avoid": ["not declared here", ""] + }, + { + "why": "an arrow assigned to exports.x, and a function to module.exports.x, are named for the property too", + "run": ["impact", "postUser", "slugify"], + "want": ["change: postUser [method]", "change: slugify [method]", "calls it (2 sites) [for postUser, slugify]"], + "avoid": ["not declared here", ""] + }, + { + "why": "an arrow that is the value of an object-literal key is named for the key and owned by the variable the literal initialises", + "run": ["impact", "Articles.all"], + "want": ["change: Articles.all [method]", "[resolved] run use.js:7"], + "avoid": ["nothing named"] + }, + { + "why": "the bare key finds it as well, and a function expression under a key is named the same way", + "run": ["impact", "Articles.get"], + "want": ["change: Articles.get [method]", "[resolved] run use.js:8"], + "avoid": ["nothing named"] + }, + { + "why": "obj.x = function is named x and owned by obj", + "run": ["impact", "titleCase"], + "want": ["change: helpers.titleCase [method]"], + "avoid": ["not declared here"] + }, + { + "why": "CONTROL: a named function expression keeps its own name, not the property's", + "run": ["impact", "realName"], + "want": ["change: realName [method]"], + "avoid": ["alias"] + }, + { + "why": "CONTROL: an arrow passed as an argument has no property to take a name from and stays a placeholder; method shorthand keeps its name", + "run": ["impact", "api.js:40", "del"], + "want": ["", "del"], + "avoid": ["Articles.del", "map"] + } + ] +} diff --git a/tests/cases/javascript/member-assigned-function-names/use.js b/tests/cases/javascript/member-assigned-function-names/use.js new file mode 100644 index 000000000..39ea8c2db --- /dev/null +++ b/tests/cases/javascript/member-assigned-function-names/use.js @@ -0,0 +1,12 @@ +const api = require('./api'); + +function run(req, res) { + api.getUser(req, res); + api.postUser(req, res); + api.slugify('A B'); + api.Articles.all(1); + api.Articles.get('x'); + api.Articles.del('x'); +} + +module.exports = { run };