Skip to content

Commit 4bbcd10

Browse files
Merge pull request #1682 from AxiomCodeAI/fix/1426-lambda-identity-file-line
changed, impact: a lambda is named by where it is; file:line takes a field or type line (#1426)
2 parents d89b631 + 8765b07 commit 4bbcd10

29 files changed

Lines changed: 646 additions & 9 deletions

File tree

‎plugins/axiomcode/skills/axiomcode/reference/changed-and-tests.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,14 @@ What to pass, and what the answer says when the question cannot be answered the
2323
| a file the base does not have | (any) | one line, `added <file> — new file, N declaration(s)`, plus each new declaration something outside the file already calls, with its impact target. Never its parameters or docstring words |
2424
| fixtures, case data, a schema | (any) | named as `outside every indexed language`, never "no change"; test-impact lists the test files whose text names them (the path, the file name, or a quoted directory), as a `[text]` tier, and says when no test names them |
2525

26+
**A lambda is part of what encloses it.** Every lambda a front end declares carries one name (`<lambda>`), so it is never
27+
the declaration an edit is charged to: an edit inside a lambda in a method is that method's `body` change, and one inside a
28+
field's initializer is that field's. A lambda nothing encloses (an entry in a module-level table) is its own `body`
29+
change, named by where it is, `module.<lambda@L15>` or `Owner.method.<lambda@L42>`, and its target is `file:line`; that
30+
name is also a target `impact` and `path` accept. Its parameter list is read from the lambda's own header, so an unchanged
31+
header is never reported as a parameter change. `impact <file>:<line>` on a field, a property, a constant or a type
32+
header line answers for that declaration; a callable written on the line still wins.
33+
2634
`test-impact` also lists an edited or new **test file** as one to run, and adds it to the command. Code that is also run as a
2735
program (`if __name__ == '__main__'`, `static void main`, `Main`) is looked for by name in the tests, since a test that starts
2836
it as a subprocess or drives it from case data has no call edge to it; when no test names it the answer says the selection is

‎plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed‎

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,23 @@ class Changed:
256256
res.append((name.lstrip('*.'), re.sub(r'\s+', ' ', typ)))
257257
return res
258258
@staticmethod
259+
def before_expression_body(h):
260+
"""a header's text up to an expression body's `=>` written after its parameter list, at bracket depth 0 (a
261+
lambda passed as a default value inside the parameters stays in them)"""
262+
depth = 0; seen = False
263+
for k, ch in enumerate(h):
264+
if ch in '([{<': depth += 1; seen = seen or ch == '('
265+
elif ch in ')]}>' and not (ch == '>' and k and h[k - 1] == '='): depth = max(0, depth - 1)
266+
elif ch == '=' and h[k + 1:k + 2] == '>' and depth == 0 and seen: return h[:k].rstrip()
267+
return h
268+
@staticmethod
269+
def lambda_header(line):
270+
"""the parameter list of the lambda written on a line, as text: `lambda x, y:` (Python), `(x, y) =>` / `x =>`
271+
(C#, Java's `->`); None when the line holds no lambda header"""
272+
t = strip_code(line)
273+
m = re.search(r'\blambda\b([^:]*):', t) or re.search(r'(\([^()]*\)|[A-Za-z_]\w*)\s*(?:=>|->)', t)
274+
return m.group(1).strip().strip('()').strip() if m else None
275+
@staticmethod
259276
def field_parts(line):
260277
"""(declared type, name) of a field line, in either spelling: `Type name = …;` or `name: Type = …`"""
261278
t = re.sub(r'\s*(//.*|/\*.*?\*/\s*|#.*)$', '', line).strip()
@@ -300,11 +317,26 @@ class Changed:
300317
if not old.strip() and new.strip(): return self.new_file(rel, new, decls, is_py), []
301318
if not decls: return [], [(rel, 'no declarations known here (not in the graph, or a different file spelling)', None)]
302319
hits = {}
303-
def narrowest(ln, kinds=None):
320+
# A LAMBDA IS PART OF WHAT ENCLOSES IT. Every lambda carries one name (`<lambda>`), so taken as the declaration
321+
# an edit changed, a body edit inside one came back as "signature <lambda> -x", handed to impact as `<lambda>`,
322+
# which matched every lambda in the graph (hundreds, most of them in unrelated fixtures), and test-impact ran
323+
# their tests. An edited line is charged to the narrowest declaration that is not a lambda; a lambda stands on
324+
# its own only where no method or field encloses it (top-level code), named `Enclosing.<lambda@L42>` and
325+
# targeted by file:line (lambda_decl).
326+
is_lam = lambda d: d[5] in P.LAMBDA_NAMES and d[3] not in ('field', 'const', 'enum_member', 'variable')
327+
def narrowest(ln, kinds=None, lambdas=False):
304328
best = None
305329
for a, b, i, k, d, n in decls:
330+
if is_lam((a, b, i, k, d, n)) != lambdas: continue
306331
if a <= ln <= b and (kinds is None or k in kinds) and (best is None or (b - a) < (best[1] - best[0])): best = (a, b, i, k, d, n)
307332
return best
333+
def lambda_decl(ln, m):
334+
"""the lambda that stands on its own at ln: one spans the line, and no method (a module body is not one)
335+
holds it, nor a field whose initializer it is"""
336+
lam = narrowest(ln, None, lambdas=True)
337+
if not lam or (m and m[3] != 'module' and m[0] <= lam[0] and lam[1] <= m[1]): return None
338+
if any(k in ('field', 'const', 'enum_member', 'variable') and a <= lam[0] <= max(b, a) for a, b, i, k, d, n in decls): return None
339+
return lam
308340
def decl_after(ln, L):
309341
"""the declaration whose header starts within 3 lines after ln (an annotation / decorator line belongs to it)"""
310342
for a, b, i, k, d, n in decls:
@@ -322,8 +354,13 @@ class Changed:
322354
for ln in sorted(old_changed):
323355
if OL[ln - 1].strip().startswith('@') and any(ln in range(1, dd[0]) for dd in decorated): continue
324356
f = next(((a, b, i, k, d, n) for a, b, i, k, d, n in decls if a == ln and k in ('field', 'const', 'enum_member', 'variable')), None)
357+
# a lambda's lines inside a field's initializer are the field's
358+
if not f: f = next(((a, b, i, k, d, n) for a, b, i, k, d, n in decls if k in ('field', 'const', 'enum_member', 'variable') and a < ln
359+
and any(is_lam(x) and x[0] <= ln <= x[1] and a <= x[0] <= max(b, a) for x in decls)), None)
325360
m = narrowest(ln, {'method', 'function', 'constructor', 'module'})
326361
t = narrowest(ln, {'class', 'interface', 'enum', 'type', 'namespace'})
362+
lam = None if f else lambda_decl(ln, m)
363+
if lam: hits.setdefault(('lambda', lam), set()).add(ln); continue
327364
key = None
328365
# a callable that starts on the type's own header line and ends within the type is one the front end
329366
# synthesised for it (an implicit constructor, a Python class body): the header is the TYPE's, or a new class
@@ -356,6 +393,15 @@ class Changed:
356393
decs = decorated.get((a, b, i, k, d, n))
357394
if a > len(OL): continue # starts past the committed file: nothing of it is there
358395
entry = dict(kind=kind, symbol=d, id=i, file=rel, line=a, end=b, old_lines=sorted(x for x in lines if x > 0), target_kind=('param' if kind == 'signature' else KIND(k)))
396+
if kind == 'lambda':
397+
# a lambda at the top level: its own body. Named by where it is, targeted by file:line in the graph's own
398+
# lines; its parameter list is compared as written, so an unchanged header is never a parameter diff
399+
entry.update(kind='body', symbol=self.g.lambda_label(i), target=self.g.lambda_target(i) or f"{rel}:{a}", target_kind='method', lambda_=True)
400+
na = new_of.get(a)
401+
oh, nh = self.lambda_header(OL[a - 1] if a <= len(OL) else ''), self.lambda_header(NL[na - 1] if na and na <= len(NL) else '')
402+
if oh is not None and nh is not None and re.sub(r'\s', '', oh) != re.sub(r'\s', '', nh): entry['detail'] = f"lambda parameters ({oh}) → ({nh})"
403+
if a in removed_lines and all(x in removed_lines for x in range(a, min(b, len(OL)) + 1) if not noise(OL[x - 1])): entry['kind'] = 'removed'
404+
out.append(entry); continue
359405
span_lines = [x for x in range(a, min(b, len(OL)) + 1) if not noise(OL[x - 1])]
360406
gone = a in removed_lines and span_lines and sum(1 for x in span_lines if x in removed_lines) >= 0.6 * len(span_lines) and \
361407
not any(re.search(rf'\b{re.escape(n)}\s*\(', NL[j - 1]) and ndepth_ok(j) for j in range(max(1, (new_of.get(a) or a) - 3), min(len(NL), (new_of.get(a) or a) + 3) + 1))
@@ -373,8 +419,13 @@ class Changed:
373419
if na and na <= len(NL):
374420
# the new header: from the mapped line, the first line holding the name, to its end
375421
for s in range(max(1, na - 2), min(len(NL), na + 6) + 1):
376-
if re.search(rf'\b{re.escape(n)}\b', NL[s - 1]):
422+
# read with comments blanked: a comment line above the header that mentions the name is not it
423+
if re.search(rf'\b{re.escape(n)}\b', strip_code(NL[s - 1], strings=False, hash_comments=is_py)):
377424
nh_raw = ' '.join(x.strip() for x in NL[s - 1:self.header_end(NL, s)]); nh = uncomment(NL[s - 1:self.header_end(NL, s)]); break
425+
# AN EXPRESSION BODY IS NOT HEADER. `int Count() => xs.Count(x => x > 0);` is one line, and read to its `;`
426+
# the whole body was header text: an edit inside the lambda it holds came back as a signature change
427+
body_arrow = self.before_expression_body
428+
oh, oh_raw, nh, nh_raw = body_arrow(oh), body_arrow(oh_raw), body_arrow(nh), body_arrow(nh_raw)
378429
# the header may start on an annotation line: its arguments are not a parameter list, and its text is
379430
# not a return type. Read the signature from the first line that is not a decoration
380431
sig = lambda h: re.sub(r'^\s*(@\w[\w.]*\s*(\([^)]*\))?\s*)+', '', h).strip()
@@ -574,10 +625,10 @@ class Changed:
574625
for r in self.g.q(f"SELECT e.callee_method_id m, s.file f, s.display d FROM call_edges e JOIN symbols s ON s.id = e.caller_id WHERE e.callee_method_id IN ({q})", *mids):
575626
if r['f'] != rel: callers.setdefault(r['m'], set()).add(r['d'])
576627
for m, cs in callers.items():
577-
i = mids[m]; sy = self.g.all_sym[i]
578-
out.append(dict(kind='added', symbol=sy['display'], id=i, file=rel, line=sy['line'], end=sy['end_line'] or sy['line'], old_lines=[],
628+
i = mids[m]; sy = self.g.all_sym[i]; lam = self.g.is_lambda(i)
629+
out.append(dict(kind='added', symbol=self.g.lambda_label(i) if lam else sy['display'], id=i, file=rel, line=sy['line'], end=sy['end_line'] or sy['line'], old_lines=[],
579630
detail=f"new, and already called from outside this file ({len(cs)} caller(s): {', '.join(sorted(cs)[:3])}{' …' if len(cs) > 3 else ''})",
580-
target=self.qualified(i, 'method') or sy['display'], target_kind='method', referenced=True))
631+
target=(self.g.lambda_target(i) if lam else None) or self.qualified(i, 'method') or sy['display'], target_kind='method', referenced=True))
581632
return out
582633
def whole_file(self, rel, why):
583634
"""every callable the graph declares in a NAMED file, as changed: what `test-impact <file>` means when there is no
@@ -586,6 +637,11 @@ class Changed:
586637
pick = [d for d in spans if d[3] in ('method', 'function', 'constructor')] or [d for d in spans if d[3] in ('class', 'interface', 'enum', 'type')]
587638
out = []
588639
for a, b, i, k, d, n in pick:
640+
if n in P.LAMBDA_NAMES:
641+
# a lambda inside a method of this file is counted with that method; one at the top level by its line
642+
if self.g.enclosing(i): continue
643+
out.append(dict(kind='named', symbol=self.g.lambda_label(i), id=i, file=rel, line=a, end=b, old_lines=[], detail=why,
644+
target=self.g.lambda_target(i) or f"{rel}:{a}", target_kind='method')); continue
589645
out.append(dict(kind='named', symbol=d, id=i, file=rel, line=a, end=b, old_lines=[], detail=why,
590646
target=self.qualified(i, 'method') or d, target_kind='type' if k in ('class', 'interface', 'enum', 'type') else 'method'))
591647
if not out: out.append(dict(kind='named', symbol=rel, id=None, file=rel, line=1, end=1, old_lines=[], detail=why + '; the graph declares nothing in it', target=None))

‎plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact‎

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,8 @@ class Impact:
294294
resolver already matched that path against the index, so there is no prefix left to check."""
295295
m = re.match(r'^(.+?):(\d+)$', typed.strip().strip('`"\''))
296296
if m and ('/' in m.group(1) or '.' in m.group(1)): return ''
297+
# `Owner.m.<lambda@L42>`: the resolver matched the enclosing member it names, so its prefix is checked
298+
if re.search(r'<\w+@L\d+>$', typed.strip()): return ''
297299
typed = re.sub(r'\(.*\)$', '', typed).replace('#', '.').strip('.')
298300
if typed.count('.') < 2: return '' # Owner.member carries no prefix to validate
299301
names = []
@@ -376,6 +378,21 @@ class Impact:
376378
f" properties file, a YAML, an XML). Do not read this as 'nothing uses it'.")
377379
return [('string', f"the string {raw} ({n} literal site(s), {d} decoration site(s))", v)]
378380
s = raw.strip('`"\'')
381+
# FILE:LINE ON A FIELD, A PROPERTY OR A TYPE HEADER is that declaration. The line resolver takes callables, so
382+
# such a line answered `no callable spans` in every language (a class line in C#, #1426); the declaration at
383+
# the line is asked about as its name would be, and a callable written on the line still wins (G.decl_at_line)
384+
mfl = re.match(r'^(.+?):(\d+)(?::\d+)?$', s)
385+
if mfl and ('/' in mfl.group(1) or '.' in mfl.group(1)) and kind in (None, 'field', 'type'):
386+
f, ln = self.g.repo_file(mfl.group(1))[0], int(mfl.group(2))
387+
files = sorted({x['file'] for x in self.g.q("SELECT DISTINCT file FROM symbols WHERE file = ? OR file LIKE ?", f, f"%/{f}")
388+
if x['file'] == f or (x['file'] or '').endswith('/' + f)})
389+
if f in files: files = [f]
390+
d = self.g.decl_at_line(files[0], ln) if len(files) == 1 else None
391+
if d and d[0] == 'field' and kind in (None, 'field'):
392+
row = self.fields.get(d[1]['rowid']) or d[1]
393+
return [('field', f"{row['kind']} {row['display']} (at {s})", [row])]
394+
if d and d[0] == 'type' and kind in (None, 'type'):
395+
return [('type', f"{d[1]['kind']} {d[1]['display']} (at {s})", [d[1]['id']])]
379396
# a lowercase dotted name is read as a configuration key when the graph has configuration facts, or when its last
380397
# segment names nothing the code declares; `util.square` ending in a real function is a declaration that missed
381398
if (kind == 'config' or (not kind and self.CONFIG_RE.match(s) and (self.has_config() or not self.declared_name(s.rsplit('.', 1)[-1]))
@@ -469,7 +486,9 @@ class Impact:
469486
# a bodiless signature beside the function it annotates (file:line on `const f: T = …`) is ONE declaration
470487
bodied = [i for i in mids if self.g.method_kind(i) not in BODILESS_KINDS] if mids else []
471488
one = len(mids) == 1 or (len(bodied) == 1 and ':' in s)
472-
if mids: out.append(('method', (self.g.disp((bodied or mids)[0]) + (f" (at {s})" if ':' in s else '') if one else f"{s} ({len(mids)} declarations)"), mids))
489+
# a lambda is named by where it is (G.lambda_label), not by the one name every lambda carries
490+
if mids: nm0 = (bodied or mids)[0]; shown = self.g.name(nm0)
491+
if mids: out.append(('method', (shown + (f" (at {s})" if ':' in s else '') if one else f"{s} ({len(mids)} declarations)"), mids))
473492
if not out:
474493
# an inherited member is written under the name the code uses: Sub.member, declared in a base. Answer for the
475494
# declaration and say where it is, instead of falling through to a name match that no rule can act on
@@ -512,7 +531,8 @@ class Impact:
512531
if soft: return []
513532
print(buf.getvalue(), end=''); raise
514533
last = re.sub(r'\(.*\)$', '', s).replace('#', '.').replace('$', '.').rstrip('.').split('.')[-1]
515-
ids = [i for i in ids if self.g.sym.get(i, {}).get('method_id') and self.g.sym[i]['kind'] not in ('library', 'written') and (self.g.sym[i]['name'] == last or ':' in s)]
534+
lam = bool(re.search(r'<\w+@L\d+>$', s)) # Owner.m.<lambda@L42>: the lambda at that line
535+
ids = [i for i in ids if self.g.sym.get(i, {}).get('method_id') and self.g.sym[i]['kind'] not in ('library', 'written') and (self.g.sym[i]['name'] == last or ':' in s or lam)]
516536
if not ids and not soft: die(f"{s} is not a method in the graph")
517537
return ids
518538
def types(self, s, soft=False):

0 commit comments

Comments
 (0)