Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -800,8 +800,10 @@ export class JsDeclarationExtractor {
* A declared constructor is NOT reused as the owner even though it is where the
* initializer runs: a field written above the constructor would then be an expression
* outside its owner's span, which is the containment invariant that catches context
* leaking down the traversal. The synthetic row spans the class, so it contains every
* initializer in it whatever the member order.
* leaking down the traversal. The synthetic row spans the fields it runs, from the first
* to the last, so it contains every initializer it owns whatever the member order. It
* does NOT span the class: a callable's span is what it defines, and the methods written
* beside the fields are its siblings (#1663).
*/
private attributeFieldInitializers(node: ts.ClassLikeDeclaration, context: WalkContext): void {
if (context.ownerType === undefined) {
Expand All @@ -812,27 +814,26 @@ export class JsDeclarationExtractor {
if (first === undefined) {
continue;
}
const owned = node.members.filter((member): member is ts.PropertyDeclaration =>
ts.isPropertyDeclaration(member) && member.initializer !== undefined
&& !isCallableExpression(member.initializer) && initializerRunsCode(member.initializer)
&& hasModifier(member, ts.SyntaxKind.StaticKeyword) === isStatic);
// One callable per class per staticness, hung off the same field the scope builder
// opened the scope on, so the row's body scope is a scope that exists.
const owner = this.synthesizeInitializerOwner(node, first, context, isStatic);
for (const member of node.members) {
if (!ts.isPropertyDeclaration(member) || member.initializer === undefined
|| isCallableExpression(member.initializer) || !initializerRunsCode(member.initializer)) {
continue;
}
if (hasModifier(member, ts.SyntaxKind.StaticKeyword) === isStatic) {
this.fieldInitOwnerByNode.set(nodeKey(member), owner);
}
const owner = this.synthesizeInitializerOwner(first, owned[owned.length - 1] ?? first, context, isStatic);
for (const member of owned) {
this.fieldInitOwnerByNode.set(nodeKey(member), owner);
}
}
}

/** The synthetic callable a class's field initializers run inside. */
/** The synthetic callable a class's field initializers run inside, spanning `first` to `last`. */
private synthesizeInitializerOwner(
node: ts.ClassLikeDeclaration, first: ts.PropertyDeclaration,
first: ts.PropertyDeclaration, last: ts.PropertyDeclaration,
context: WalkContext, isStatic: boolean,
): string {
const at = this.positionOf(node);
const start = this.positionOf(first);
const at = { ...start, endLine: this.positionOf(last).endLine };
const bodyScope = this.options.binder.scopeOpenedBy.get(nodeKey(first)) ?? context.scope;
const ownerType = context.ownerType;
const name = isStatic ? '<static-init>' : '<instance-init>';
Expand Down
51 changes: 42 additions & 9 deletions plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,31 @@ def anon_depth(display):
return len(_ANON_SCOPE.findall(display or ''))


# A class's synthesized initializer spans the fields it runs, and a method written between two of them lies inside
# that span without being written inside any initializer (#1663): it is the initializer's sibling, and its display
# says so, the same owner and no scope of the initializer's in between. What an initializer does define (an arrow or
# a function expression handed to a call in it) has a display that is not the initializer's sibling.
_NAMED_INIT = re.compile(r'(?:^|\.)<(?:static-init|instance-init|clinit|classbody)>$')


def _sibling_of_init(display, init_display):
"""`Type.m` beside `Type.<static-init>`: a member of the initializer's owner, not a callable written inside it"""
if not init_display or not _NAMED_INIT.search(init_display) or not display or anon_depth(display): return False
owner = init_display.rsplit('.', 1)[0] if '.' in init_display else ''
return bool(owner) and display.rsplit('.', 1)[0] == owner and '.' in display


def init_holds(init_display, init_qname, display, qname):
"""a named initializer holds a callable on its own line span: `static cfg = make({ run() {…} });` gives the
initializer and `run` the same one line, and line spans alone call them siblings. The qualified name says `run`
is written under the initializer's owner (`src/a.ObjLit.run`), and the display that it is not a member of it
(`run`, `ObjLit.<anonymous-class>.m`), so the initializer defines it. A function written after a one-line class
has no such qualified name, and a method of the class is the initializer's sibling."""
if not (init_display and _NAMED_INIT.search(init_display) and init_qname and qname and display): return False
if '.' not in init_qname or _sibling_of_init(display, init_display): return False
return qname.startswith(init_qname.rsplit('.', 1)[0] + '.') and qname != init_qname


def _within(inner, outer):
"""a call site's (line, col, end_line, end_col) lies inside another's and is not the same one"""
return inner != outer and outer[:2] <= inner[:2] and inner[2:] <= outer[2:]
Expand All @@ -294,24 +319,32 @@ def defines_edges(callables, sites=None):
"""(definer, defined, 'defines') for callables given as (file, line, end_line, id, display, method_id[, qualified_name]).
`sites(method_ids)` returns (caller_id, line, col, end_line, end_col) for the call sites of those callables; it is
asked only about lines where more than one callable could have defined an anonymous one."""
byfile = {}; mid_of = {}; lex = {}
byfile = {}; mid_of = {}; lex = {}; disp_of = {}; qn_of = {}
for f, ln, en, i, disp, mid, *qn in callables:
if not (ln and en) or str(mid or i).startswith('generated:'): continue
byfile.setdefault(f, []).append((ln, -en, anon_depth(disp), i)); mid_of[i] = mid or i
byfile.setdefault(f, []).append((ln, -en, anon_depth(disp), i)); mid_of[i] = mid or i; disp_of[i] = disp
qn_of[i] = qn[0] if qn else None
if qn and qn[0] and str(mid or i).startswith(_LEXICAL_IDS): lex[i] = qn[0]
holds = lambda c, x: init_holds(disp_of[c], qn_of[c], disp_of[x], qn_of[x])
def may_hold(c, x):
"""on an equal span: c may be x's definer unless both names follow the nesting and x's does not extend c's"""
return c not in lex or x not in lex or lex[x].startswith(lex[c] + '.')
"""on an equal span: c may be x's definer unless both names follow the nesting and x's does not extend c's, or
c is a class's initializer holding x (a member of an object or class written in it: `src/a.C.run`)"""
return c not in lex or x not in lex or lex[x].startswith(lex[c] + '.') or holds(c, x)
parent = {}; groups = []
for f, rows in byfile.items():
rows.sort(); st = []
# at an equal span a named initializer comes first, so what it holds on its line finds it on the stack
rows.sort(key=lambda r: (r[0], r[1], r[2], not _NAMED_INIT.search(disp_of[r[3]] or ''), r[3])); st = []
for ln, neg, d, i in rows:
# pop what ends before this one ends (END against END, not against this one's start: the two agree on nested
# spans and not on overlapping ones), and a sibling: the same span at the same depth
while st and (st[-1][1] < -neg or (st[-1][0] == ln and st[-1][1] == -neg and st[-1][2] >= d)): st.pop()
# past an equal span the name says it is not written in (kept on the stack: a later one may be)
# spans and not on overlapping ones), and a sibling: the same span at the same depth, unless it is an
# initializer holding this one on its line (init_holds)
while st and (st[-1][1] < -neg or (st[-1][0] == ln and st[-1][1] == -neg and st[-1][2] >= d
and not holds(st[-1][3], i))): st.pop()
# past an equal span the name says it is not written in (kept on the stack: a later one may be), and past a
# class's initializer when this is a member of that class beside it (#1663)
j = len(st) - 1
while j >= 0 and st[j][0] == ln and st[j][1] == -neg and not may_hold(st[j][3], i): j -= 1
while j >= 0 and (_sibling_of_init(disp_of[i], disp_of[st[j][3]])
or (st[j][0] == ln and st[j][1] == -neg and not may_hold(st[j][3], i))): j -= 1
if j >= 0 and st[j][3] != i: parent[f, i] = st[j][3]
st.append((ln, -neg, d, i))
# equal spans holding an anonymous callable and more than one other candidate to have written it
Expand Down
4 changes: 4 additions & 0 deletions plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,10 @@ class G:
r = self.q("SELECT id, end_line - line AS span FROM symbols WHERE file = ? AND line <= ? AND end_line >= ? AND method_id IS NOT NULL AND kind <> 'module' ORDER BY span", f, ln, ln)
if r:
ids = [x['id'] for x in r if x['span'] == r[0]['span']]
# a field initializer and what it holds on its one line (`static cfg = make({ run() {…} });`) share the
# span; the line names the innermost, the callable written inside the initializer (#1663)
sy = lambda i: (self.sym.get(i, {}).get('display'), self.sym.get(i, {}).get('qualified_name'))
ids = [j for j in ids if not any(ax_edges.init_holds(*sy(j), *sy(i)) for i in ids if i != j)] or ids
# AN ANNOTATION IS NOT THE FUNCTION. `export const f: (a: A) => B = (a) => …` puts a one-line call
# signature and the arrow on the same line, and the signature, being shortest, won: `impact file:171`
# answered for `<function-type>`. Where every pick is a bodiless signature, the callable that starts
Expand Down
13 changes: 13 additions & 0 deletions tests/cases/javascript/field-initializer-is-a-sibling/case.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{"lang": "javascript", "src": "src",
"checks": [
{"why": "a static field initializer defines only what is written in it, not the methods of its class", "run": ["path", "Registry.<static-init>", "tick"], "want": ["no chain of resolved calls connects"], "avoid": ["defines"], "expect_error": true},
{"why": "nor does an instance field initializer", "run": ["path", "Panel.<instance-init>", "tick"], "want": ["no chain of resolved calls connects"], "avoid": ["defines"], "expect_error": true},
{"why": "a method written between two initialized fields is their sibling, not written inside them", "run": ["path", "Mixed.<static-init>", "tick"], "want": ["no chain of resolved calls connects"], "avoid": ["defines"], "expect_error": true},
{"why": "control: an arrow handed to a call in the initializer is still reached from it, and a function inside a method is not", "run": ["path", "Host.<static-init>", "tick"], "want": ["callback_registered", "<arrow>", "tick"], "avoid": ["defines", "helper"]},
{"why": "control: a function declared inside a method is still defined by it", "run": ["path", "Host.go", "tick"], "want": ["Host.go", "helper", "tick"]},
{"why": "a one-line static initializer defines the object-literal method it hands to a call", "run": ["path", "ObjLit.<static-init>", "tick"], "want": ["[defines", "run", "tick"]},
{"why": "and the method of a class expression written in it", "run": ["path", "ClsExpr.<static-init>", "tick"], "want": ["[defines", "<anonymous-class>.m", "tick"]},
{"why": "a one-line instance initializer defines the method of the plain object it holds", "run": ["path", "PlainObj.<instance-init>", "tick"], "want": ["[defines", "onClick", "tick"]},
{"why": "and of the object literal it hands to a call", "run": ["path", "InstObj.<instance-init>", "tick"], "want": ["[defines", "fetch", "tick"]},
{"why": "the line of such an initializer names the callable written inside it, not both", "run": ["impact", "src/app.js:32"], "want": ["change: run (at src/app.js:32)"], "avoid": ["callables at src/app.js:32"]},
{"why": "control: on a one-line class the method beside the initializer, and a function after the class, stay its siblings", "run": ["path", "OneLine.<static-init>", "tick"], "want": ["no chain of resolved calls connects"], "avoid": ["defines"], "expect_error": true}]}
53 changes: 53 additions & 0 deletions tests/cases/javascript/field-initializer-is-a-sibling/src/app.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
function boot() { return 1; }
function tick() { return 2; }
function wrap(f) { return f; }

class Registry {
static count = boot();
add(x) { return tick(); }
remove(x) { return tick(); }
}

class Panel {
size = boot();
open() { return tick(); }
close() { return tick(); }
}

class Mixed {
static a = boot();
run() { return tick(); }
static b = boot();
stop() { return tick(); }
}

class Host {
static cb = wrap(() => tick());
go() { function helper() { return tick(); } return helper(); }
}

function make(o) { return o; }

class ObjLit {
static cfg = make({ run() { return tick(); } });
other() { return 1; }
}

class ClsExpr {
static Inner = wrap(class { m() { return tick(); } });
other() { return 1; }
}

class PlainObj {
handlers = { onClick() { return tick(); } };
other() { return 1; }
}

class InstObj {
api = make({ fetch() { return tick(); } });
other() { return 1; }
}

class OneLine { static n = boot(); m() { return tick(); } } function after() { return tick(); }

module.exports = { Registry, Panel, Mixed, Host, ObjLit, ClsExpr, PlainObj, InstObj, OneLine, after };
Loading