From 5d01fdd9689c31bae642f4f7cff65327bea3d6a6 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Wed, 30 Sep 2026 02:19:27 -0700 Subject: [PATCH 1/3] context: the flow continues through a middleware's next(), names a dispatch point's handlers, and starts at the producer - a registered callback that calls its own parameter (next) continues into what the same function registers after it on the same receiver, in order, with the registration line - a call through a value whose candidates exceed the fan cap names the registered handlers; the fan counts project, non-test candidates only - a question naming a producing verb (publish, send, forward...) that lands on the producing method starts at its caller; a caller joined only by name is one labelled step - a nested function's step names the factory that made it, read from spans Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../axiomcode/scripts/axiomcode-context | 212 +++++++++++++++++- .../flow-through-dispatch-points/case.json | 29 +++ .../flow-through-dispatch-points/src/bus.js | 54 +++++ .../src/gateway.js | 60 +++++ tests/cases/typescript/explain-flow/case.json | 16 +- .../typescript/explain-flow/src/middleware.ts | 19 ++ 6 files changed, 379 insertions(+), 11 deletions(-) create mode 100644 tests/cases/javascript/flow-through-dispatch-points/case.json create mode 100644 tests/cases/javascript/flow-through-dispatch-points/src/bus.js create mode 100644 tests/cases/javascript/flow-through-dispatch-points/src/gateway.js create mode 100644 tests/cases/typescript/explain-flow/src/middleware.ts diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context index 16ad22e8..d7077e55 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context @@ -761,7 +761,167 @@ CONTAINER = frozenset('items keys values get pop popitem append extend add updat 'length size isEmpty contains equals hashCode'.split()) -def flow(g, roots, depth_cap=FLOW_DEPTH, max_steps=FLOW_STEPS): +DISPATCH_NAMED = 6 # the registered handlers a dispatch point names; the rest are counted +HANDOFF = {} # (step, registration line, file) -> the declaration that registered it +ENTRY_NOTE = {} # entry step -> why the flow starts there, when it is not a match of the task's words + + +def param_names(g, x): + """the parameter names of `x`, read from the text of its signature: the first balanced (...) after its name.""" + sy = g.sym.get(x) or {} + if not sy.get('file') or not sy.get('line'): return set() + text = ' '.join(source_lines(g.repo, sy['file'])[sy['line'] - 1:sy['line'] + 3]) + nm = sy.get('name') or '' + k = text.find(nm + '(') if nm and nm + '(' in text else 0 + a = text.find('(', k) + if a < 0: return set() + depth, b = 0, a + for b in range(a, len(text)): + depth += {'(': 1, ')': -1}.get(text[b], 0) + if depth == 0: break + return set(re.findall(r'[A-Za-z_$][\w$]*', re.sub(r'=[^,)]*', '', text[a + 1:b]))) - {'self', 'this'} + + +def handoffs(g, rows, project): + """where a registered callback HANDS ON by calling its own parameter: `function auth(req, res, next) { next(); }` + registered with `router.use(auth)`. The framework runs the chain, so no call edge leaves `next()`; what it runs is + what the same function registers after it on the same receiver with the same method (`router.use('/api', proxy)`), + in the order written. Returns step -> [(registration line, continuation, 'handoff', file)], FLOW_FAN at most. + A callback that calls no parameter (a route handler that sends the response) ends the chain and continues nowhere; + a registration on another receiver in the same function is another chain.""" + regs = [r for r in rows if r['t'] == 'callback_registered' and r['m'] and project(r['m'])] + if not regs: return {} + sites = {r['id']: r for r in g.q("""SELECT DISTINCT s.id, s.caller_id, s.callee_name, s.file_path, s.start_line, s.start_column + FROM call_sites s JOIN call_edges e ON e.call_site_id = s.id + WHERE e.tier = 'callback_registered'""")} + def site_text(s): + ln = source_lines(g.repo, g.site_file(s['file_path'])) + a = s['start_line'] or 0 + if not 0 < a <= len(ln): return '' + return ' '.join([ln[a - 1][max((s['start_column'] or 1) - 1, 0):]] + ln[a:a + 5]) + def receiver(text, name): + m_ = re.match(r'\s*([\w$.\[\]]*?)\s*\??\.\s*' + re.escape(name) + r'\s*\(', text) + return m_.group(1) if m_ else '' + def arg_at(text, y): + """where callback `y` is written among the site's arguments: its own name, or the factory that makes it + (`requireAuth(auth)` makes the middleware). None when it is passed through a variable (`limiter`).""" + p = made_by(g, y) + hits = [m_.start() for nm in {(g.sym.get(y) or {}).get('name'), (g.sym.get(p) or {}).get('name') if p else None} + if nm and not nm.startswith('<') for m_ in [re.search(r'(? [(line, col, arg, callback, file)] + where = {} # callback -> [(chain key, (line, col, arg))] + for r in regs: + s = sites.get(r['sid']) + if not s or not s['callee_name'] or s['callee_name'] in CONTAINER: continue + text = site_text(s) + key = (s['caller_id'], s['callee_name'], receiver(text, s['callee_name'])) + pos = (s['start_line'] or 0, s['start_column'] or 0, arg_at(text, r['m'])) + chain[key].append(pos + (r['m'], g.site_file(s['file_path']))) + where.setdefault(r['m'], []).append((key, pos)) + calls = collections.defaultdict(set) # callback -> the names it calls + ids = list(where) + for k in range(0, len(ids), 500): + part = ids[k:k + 500] + for c, nm in g.q(f"SELECT caller_id, callee_name FROM call_sites WHERE caller_id IN ({','.join('?' * len(part))})", *part): + if nm: calls[c].add(nm) + out = {} + for x, regd in where.items(): + hands = calls.get(x, set()) & param_names(g, x) + if not hands: continue + nxt = [] + for key, (l0, c0, a0) in regd: + # a later registration follows; an argument of the SAME call follows only when both are written there and + # it comes after (`use(auth, limiter)`): one passed through a variable has no place to order it by + nxt += [(l, y, f, key[0]) for l, c, a, y, f in sorted(chain[key], key=lambda t: (t[0], t[1], t[2] or 0)) + if y != x and ((l, c) > (l0, c0) or ((l, c) == (l0, c0) and a0 is not None and (a is None or a > a0)))] + seen, keep_ = set(), [] + for l, y, f, by in nxt: + if y in seen: continue + seen.add(y); keep_.append((l, y, 'handoff', f)) + HANDOFF[(y, l, f)] = by + if keep_: out[x] = keep_[:FLOW_FAN] + return out + + +PRODUCE = frozenset('publish emit send forward produce enqueue broadcast'.split()) + + +def asked_verbs(task): + """the producing verbs the task is written with, any inflection: 'published', 'forwards', 'sending'.""" + out = set() + for w in re.findall(r'[a-z]+', task_text(task).lower()): + for v in PRODUCE: + if w == v or (w.startswith(v[:-1] if v.endswith('e') else v) and len(w) - len(v) <= 3): out.add(v) + return out + + +def producer_first(g, roots, seeds, task, terms): + """a question naming the PRODUCING side ('how are events published and dispatched') matched the bus's own + `publish`, and the flow started inside the bus: the code that publishes was never shown. When an entry point is + itself named for a verb the task asks with, the flow starts at the project code that calls it from outside its own + type or module — the caller matching most of the task's words — and runs through it. A root the task does not ask + that way stays. + An injected bus is often an untyped receiver (`this.bus.publish(...)`), so no edge reaches the method from the code + that publishes. When no resolved caller qualifies and the name is declared in few places (GAP_DECLS), a caller + that writes the name on an unresolved site is taken, labelled `by name`, and the method stays the next root.""" + verbs = asked_verbs(task) + if not verbs: return roots + tset = set(terms) + usable = lambda c, sy: (c.get('method_id') and not c.get('is_test') and not (c.get('file') or '').startswith('<') + and not (c.get('name') or '<').startswith('<') + and (c.get('qualified_name') or '').rsplit('.', 1)[0] != (sy.get('qualified_name') or '').rsplit('.', 1)[0]) + score = lambda c: len(tset & set(subtokens((c.get('display') or '') + ' ' + (c.get('file') or '')))) + for s in seeds: + sy = g.sym.get(s) or {} + nm = sy.get('name') or '' + toks = subtokens(nm) + if not sy.get('method_id') or not toks or toks[0] not in verbs: continue + cands = [] + for r in g.q("SELECT DISTINCT caller_id c, tier t FROM call_edges WHERE callee_method_id = ?", s): + c = g.sym.get(r['c']) or {} + if usable(c, sy) and ax_edges.direct_cert(r['t']) == 'resolved': + cands.append((-score(c), c.get('file') or '', c.get('line') or 0, r['c'], None)) + if not any(c_[0] for c_ in cands): + decls = sum(1 for x in g.sym.values() if x.get('name') == nm and x.get('method_id') and not x.get('is_test') + and not (x.get('file') or '').startswith('<')) + if decls <= GAP_DECLS: + for r in g.q("""SELECT s.caller_id c, min(s.start_line) l FROM call_sites s + WHERE s.callee_name = ? AND NOT EXISTS (SELECT 1 FROM call_edges e WHERE e.call_site_id = s.id + AND e.callee_method_id IS NOT NULL) + GROUP BY s.caller_id""", nm): + c = g.sym.get(r['c']) or {} + if usable(c, sy): cands.append((-score(c), c.get('file') or '', c.get('line') or 0, r['c'], r['l'])) + cands = [c_ for c_ in cands if c_[0]] # no caller the task's words point at: which producer is a guess + if not cands: continue + _h, _f, _l, p, byname = min(cands, key=lambda c_: (c_[4] is not None,) + c_[:3]) + if byname is None: + return [p] + [x for x in roots if x != s and x != p][:FLOW_ROOTS - 1] + ENTRY_NOTE[p] = f"calls {nm}() by name at L{byname} (receiver not typed): the producer of {g.disp(s)}, which follows" + return [p, s] + return roots + + +def made_by(g, x): + """the function a nested function is written inside — the factory that makes a closure (`requireAuth` returning + the middleware) — or None for a top-level function or a class member. + Read from the spans, which every language records the same way (a TypeScript qualified name is flat, + `src/gateway#inner`): the narrowest named function whose span holds this one.""" + sy = g.sym.get(x) or {} + f, a, b = sy.get('file'), sy.get('line'), sy.get('end_line') or sy.get('line') + if not sy.get('method_id') or not f or not a or f.startswith('<'): return None + if not hasattr(g, '_fn_spans'): + g._fn_spans = collections.defaultdict(list) + for i, s in g.sym.items(): + if s.get('method_id') and s.get('line') and not (s.get('name') or '<').startswith('<'): + g._fn_spans[s.get('file')].append((s['line'], s.get('end_line') or s['line'], i)) + best = None + for c, d, i in g._fn_spans.get(f, ()): + if i != x and c <= a and b <= d and (c, d) != (a, b) and (best is None or d - c < best[0]): best = (d - c, i) + return best[1] if best else None + + +def flow(g, roots, depth_cap=FLOW_DEPTH, max_steps=FLOW_STEPS, shallow=frozenset()): """The call flow from `roots`, in the order the calls are written. Nothing here is ranked or scored. Where a flow STARTS is the reader's decision (--from) or the entry points this verb already chose; which of a @@ -779,11 +939,15 @@ def flow(g, roots, depth_cap=FLOW_DEPTH, max_steps=FLOW_STEPS): gaps = collections.defaultdict(list) # caller -> [(line, name)] fan = collections.Counter() rows = g.q("""SELECT e.call_site_id sid, e.caller_id c, e.callee_method_id m, e.tier t, coalesce(e.callee_label, s.callee_name) n, - s.start_line l, s.file_path f + s.start_line l, s.file_path f, s.callee_name cn FROM call_edges e LEFT JOIN call_sites s ON s.id = e.call_site_id""") + project = lambda m: m in g.sym and not (g.sym[m].get('file') or '').startswith('<') and not g.sym[m].get('is_test') + # the fan of a site counts what a flow could show: a test's handler registered on the same bus is one more candidate + # of `handler(envelope)`, and six of them hid the three the project registers for r in rows: - if r['m']: fan[r['sid']] += 1 + if r['m'] and project(r['m']): fan[r['sid']] += 1 exits = collections.defaultdict(list) # caller -> [(line, library call name)] that hand the flow to a library + wide = collections.defaultdict(dict) # caller -> {(line, name written): [candidates]} too many to be steps for r in rows: lib = (r['t'] or '').startswith('boundary_lib') or (r['m'] in g.sym and (g.sym[r['m']].get('file') or '') == '') if lib: @@ -791,9 +955,14 @@ def flow(g, roots, depth_cap=FLOW_DEPTH, max_steps=FLOW_STEPS): if nm and DISPATCH(nm): exits[r['c']].append((r['l'] or 0, nm, 'library call, target not followed')) continue if r['m'] and r['m'] in g.sym and r['m'] != r['c']: - sy = g.sym[r['m']] - if (sy.get('file') or '').startswith('<') or sy.get('is_test'): continue - if ax_edges.direct_cert(r['t']) != 'resolved' and fan[r['sid']] > FLOW_FAN: continue + if not project(r['m']): continue + if ax_edges.direct_cert(r['t']) != 'resolved' and fan[r['sid']] > FLOW_FAN: + # a call through a VALUE (`handler(envelope)`, `table[key].apply(...)`) whose candidates are named + # otherwise is a dispatch point: what runs there is what was registered, so it is named on the step. + # `pair[0]` matched to every `__getitem__` calls candidates of its own name, and stays unlisted. + if r['cn'] and r['cn'] != g.sym[r['m']].get('name') and r['t'] != 'callback_registered': + wide[r['c']].setdefault((r['l'] or 0, r['cn']), []).append(r['m']) + continue out[r['c']].append((r['l'] or 0, r['m'], r['t'], r['f'])) elif not r['m'] and r['t'] == 'ambiguous_unknown' and r['n']: gaps[r['c']].append((r['l'] or 0, r['n'])) @@ -809,7 +978,9 @@ def flow(g, roots, depth_cap=FLOW_DEPTH, max_steps=FLOW_STEPS): for b, ms in held.items(): if len(ms) <= FLOW_FAN: out[b] += [(0, m, 'value', None) for m in ms] for x in out: out[x].sort(key=lambda r: r[0]) - declared = collections.Counter((sy.get('name') or '') for sy in g.sym.values() + for x, hs in handoffs(g, rows, project).items(): + out[x] += [h for h in hs if h[1] not in {y for _l, y, _t, _f in out[x]}] + declared =collections.Counter((sy.get('name') or '') for sy in g.sym.values() if sy.get('method_id') and not (sy.get('file') or '').startswith('<') and not sy.get('is_test')) for x in list(gaps): # a call the graph could not resolve to a name declared NOWHERE here is a library the index does not hold (a @@ -824,6 +995,7 @@ def flow(g, roots, depth_cap=FLOW_DEPTH, max_steps=FLOW_STEPS): for _d in range(depth_cap): nxt = [] for x in frontier: + if x in shallow: continue for _l, y, _t, _f in out.get(x, ()): if y not in keep and len(keep) < max_steps: keep.add(y); nxt.append(y) @@ -837,7 +1009,7 @@ def flow(g, roots, depth_cap=FLOW_DEPTH, max_steps=FLOW_STEPS): steps.append((depth, x, via, seen[x])); return seen[x] = sum(1 for st in steps if st[3] is None) + 1 # the printed step number steps.append((depth, x, via, None)) - if depth >= depth_cap: return + if depth >= depth_cap or x in shallow: return done = set() for l, y, t, f in out.get(x, ()): if y in done or y not in keep: continue @@ -852,6 +1024,12 @@ def flow(g, roots, depth_cap=FLOW_DEPTH, max_steps=FLOW_STEPS): marks = {} for x in {x for _d, x, _v, again in steps if again is None}: m = [f"leaves the graph: {nm}() L{l} ({why})" for l, nm, why in sorted(set(exits.get(x, ())))[:2]] + for (l, nm), ms in sorted(wide.get(x, {}).items()): + ms = sorted(set(ms), key=lambda y: (g.sym[y].get('file') or '', g.sym[y].get('line') or 0)) + names = [g.disp(y) + (f" ({g.loc(y)})" if (g.sym[y].get('name') or '<').startswith('<') else '') for y in ms] + m.append(f"dispatch point: {nm}() L{l} runs one of {len(ms)} registered: " + ', '.join(names[:DISPATCH_NAMED]) + + (f" … +{len(ms) - DISPATCH_NAMED} more" if len(ms) > DISPATCH_NAMED else '') + + " — `--from ` follows it") if not any(y in keep for _l, y, _t, _f in out.get(x, ())) and not gaps.get(x) and bodiless(g, x): bound = text_bindings(g, [x], want_ext=('.xml', '.sql'))[:1] # the best-proven one: a mapper of this type m.append("no body in the graph (interface/abstract): the flow cannot continue from here" @@ -916,8 +1094,14 @@ def print_flow(g, roots, steps, gaps, chosen, show_source=False, marks=None): head, where = f"{pad}{arrow} {g.disp(x)}", (f"called at L{l}: {src}" if src else f"called at L{l}") if t == 'value': where = "held by the step above: assigned or passed where it is expected, so a call to it runs this" + if t == 'handoff': + by = HANDOFF.get((x, l, f)) + where = (f"runs next: registered after it by {g.disp(by) if by else '?'} at {g.site_file(f)}:{l}" + + (f": {src}" if src else '') + " — the step above hands on by calling its parameter") else: - head, where = g.disp(x), "entry point" + head, where = g.disp(x), "entry point" + (f" · {ENTRY_NOTE[x]}" if x in ENTRY_NOTE else '') + mk_by = made_by(g, x) + if mk_by: where += f" · made by {g.disp(mk_by)}" n += 1 print(f" {n:2} {head:60.60} {g.loc(x)}") print(f" {pad} {where}") @@ -1170,10 +1354,18 @@ def main(argv): cands += [x for x in type_members(g, [s_ for s_, _t, _sc in seeds], scored) if x not in cands] asked = {s_ for s_, _why in named} roots_ = sorted(cands, key=lambda x: x not in asked and bodiless(g, x))[:FLOW_ROOTS] - roots, steps, gaps, marks = flow(g, roots_, max_steps=FLOW_SOURCE_STEPS if show_source else FLOW_STEPS) + roots_ = producer_first(g, roots_, [s_ for s_, _t, _sc in seeds], task, terms) + # a producer joined only by name is shown as one step: its code holds the call, and the budget goes to the + # mechanism the task asked about rather than to everything else the producer does + roots, steps, gaps, marks = flow(g, roots_, max_steps=FLOW_SOURCE_STEPS if show_source else FLOW_STEPS, + shallow=frozenset(ENTRY_NOTE)) RESULT['flow'] = [{'step': k + 1, 'depth': d, 'name': g.disp(x), 'at': g.loc(x), 'called_at_line': (via[0] if via else None), 'certainty': (ax_edges.direct_cert(via[1]) if via else 'entry'), 'repeat_of': again, 'unresolved': [f"{nm} L{l}" for l, nm in gaps.get(x, ())[:4]], + 'made_by': (g.disp(made_by(g, x)) if made_by(g, x) else None), + 'why_here': (ENTRY_NOTE.get(x) if not via else None), + 'registered_by': (g.disp(HANDOFF[(x, via[0], via[2])]) if via and via[1] == 'handoff' + and (x, via[0], via[2]) in HANDOFF else None), 'leaves_graph': (marks.get(x, []) if again is None else [])} for k, (d, x, via, again) in enumerate(steps)] if not print_flow(g, roots, steps, gaps, bool(starts), show_source, marks): diff --git a/tests/cases/javascript/flow-through-dispatch-points/case.json b/tests/cases/javascript/flow-through-dispatch-points/case.json new file mode 100644 index 00000000..94f49ba9 --- /dev/null +++ b/tests/cases/javascript/flow-through-dispatch-points/case.json @@ -0,0 +1,29 @@ +{"lang": "javascript", "src": "src", + "checks": [ + {"why": "a middleware registered on a router calls its own `next` parameter; the flow used to stop there with `next()` unresolved. It continues into what the same router registers after it, in order, and names where that was registered and which factory made the continuation", + "run": ["context", "how does the gateway authenticate a request and forward it upstream", "--from", "checkBearer", "--source"], + "want": [" 1 checkBearer ", "⇢ throttleRequest", "⇢ forwardUpstream", "runs next: registered after it by gatewayRouter at src/gateway.js:50: router.use('/docs', createForwarder", "made by createForwarder", "→ relayBody"]}, + {"why": "control: a registration on another receiver in the same function (`audit.use`) is another chain, not a step of this one", + "run": ["context", "how does the gateway authenticate a request and forward it upstream", "--from", "checkBearer", "--source"], + "avoid": ["⇢ recordHit"]}, + {"why": "control: a middleware passed through a variable after another argument of the same call does not loop back to the one written before it; it continues into the later registration", + "run": ["context", "how is a request throttled", "--from", "throttleRequest"], + "want": ["⇢ forwardUpstream"], + "avoid": ["checkBearer"]}, + {"why": "control: a handler that does not call a parameter ends the chain there, so the one registered after it is not a step of it", + "run": ["context", "how does the gateway forward a request upstream", "--from", "forwardUpstream"], + "want": ["→ relayBody"], + "avoid": ["lastResort"]}, + {"why": "control: route handlers registered one after another do not call a parameter, so neither continues into the next", + "run": ["context", "how does the status route render", "--from", "renderStatus"], + "avoid": ["renderVersion"]}, + {"why": "a call through a parameter that the graph resolves to more candidates than a step can hold is a dispatch point: the flow names the registered handlers there instead of dropping the call without a word", + "run": ["context", "how does the bus deliver an envelope", "--from", "TopicBus.deliver"], + "want": ["dispatch point: handler() L22 runs one of 4", "onInvoiceIssued", "onInvoiceOverdue"]}, + {"why": "a question that names the producing side (publish) starts at the code that publishes, not at the bus's own publish method, so the flow reads producer -> publish -> deliver -> dispatch", + "run": ["context", "how are invoice events published on the bus and dispatched to handlers", "--source"], + "want": [" 1 InvoiceService.issue ", "→ TopicBus.publish", "→ TopicBus.deliver"]}, + {"why": "control: a question that does not name the producing side starts where its words land", + "run": ["context", "how does the bus deliver an envelope to a handler"], + "want": ["how it runs —"], + "avoid": [" 1 InvoiceService.issue "]}]} diff --git a/tests/cases/javascript/flow-through-dispatch-points/src/bus.js b/tests/cases/javascript/flow-through-dispatch-points/src/bus.js new file mode 100644 index 00000000..908bc6ff --- /dev/null +++ b/tests/cases/javascript/flow-through-dispatch-points/src/bus.js @@ -0,0 +1,54 @@ +export class TopicBus { + constructor() { + this.subscribers = new Map(); + } + + subscribe(topic, handler) { + if (!this.subscribers.has(topic)) this.subscribers.set(topic, []); + this.subscribers.get(topic).push(handler); + } + + publish(topic, payload) { + const envelope = { topic, payload }; + this.deliver(envelope); + return envelope; + } + + deliver(envelope) { + for (const handler of this.subscribers.get(envelope.topic) ?? []) this.runHandler(handler, envelope); + } + + runHandler(handler, envelope) { + handler(envelope); + } +} + +export class InvoiceService { + constructor(bus, store) { + this.bus = bus; + this.store = store; + } + + issue(invoice) { + this.store.save(invoice); + this.bus.publish('invoice.issued', invoice); + } +} + +function onInvoiceIssued(envelope) { return envelope.payload; } +function onInvoicePaid(envelope) { return envelope.payload; } +function onInvoiceVoided(envelope) { return envelope.payload; } +function onInvoiceOverdue(envelope) { return envelope.payload; } + +export function wireInvoiceHandlers(bus) { + bus.subscribe('invoice.issued', onInvoiceIssued); + bus.subscribe('invoice.paid', onInvoicePaid); + bus.subscribe('invoice.voided', onInvoiceVoided); + bus.subscribe('invoice.overdue', onInvoiceOverdue); + return bus; +} + +export function createBilling(store) { + const bus = wireInvoiceHandlers(new TopicBus()); + return new InvoiceService(bus, store); +} diff --git a/tests/cases/javascript/flow-through-dispatch-points/src/gateway.js b/tests/cases/javascript/flow-through-dispatch-points/src/gateway.js new file mode 100644 index 00000000..f8c0395f --- /dev/null +++ b/tests/cases/javascript/flow-through-dispatch-points/src/gateway.js @@ -0,0 +1,60 @@ +import express from 'express'; + +export function requireAuth(tokens) { + return function checkBearer(req, res, next) { + const token = req.get('authorization'); + if (!token) return next(new Error('bearer token required')); + req.user = tokens.verify(token); + next(); + }; +} + +export function createForwarder(target) { + return function forwardUpstream(req, res) { + relayBody(target, req, res); + }; +} + +function relayBody(target, req, res) { + res.send({ target, path: req.path }); +} + +function recordHit(req, res, next) { + req.seen = true; + next(); +} + +function makeThrottle() { + return function throttleRequest(req, res, next) { + next(); + }; +} + +function lastResort(req, res) { + res.status(404).end(); +} + +function renderStatus(req, res) { + res.send('ok'); +} + +function renderVersion(req, res) { + res.send('1'); +} + +export function gatewayRouter(tokens, audit) { + const router = express.Router(); + const throttle = makeThrottle(); + audit.use(recordHit); + router.use(requireAuth(tokens), throttle); + router.use('/docs', createForwarder('http://docs.internal')); + router.use(lastResort); + return router; +} + +export function statusRouter() { + const router = express.Router(); + router.get('/status', renderStatus); + router.get('/version', renderVersion); + return router; +} diff --git a/tests/cases/typescript/explain-flow/case.json b/tests/cases/typescript/explain-flow/case.json index 3964c5bd..f1f9b383 100644 --- a/tests/cases/typescript/explain-flow/case.json +++ b/tests/cases/typescript/explain-flow/case.json @@ -143,6 +143,20 @@ "avoid": [ "lie within 3 hops of the entry points; `--budget N` lists them" ] + }, + { + "why": "a middleware that calls its `next` parameter continues into what the same app registers after it (the TypeScript graph records the registration as the JavaScript one does); the continuation names the factory that made it", + "run": [ + "context", + "how does a site request get its session checked and relayed", + "--from", + "checkSession" + ], + "want": [ + "⇢ relayUpstream", + "runs next: registered after it by mountSite", + "made by createRelay" + ] } ] -} \ No newline at end of file +} diff --git a/tests/cases/typescript/explain-flow/src/middleware.ts b/tests/cases/typescript/explain-flow/src/middleware.ts new file mode 100644 index 00000000..254ca634 --- /dev/null +++ b/tests/cases/typescript/explain-flow/src/middleware.ts @@ -0,0 +1,19 @@ +type Next = (err?: unknown) => void; + +export function requireSession(sessions: { check(token: string): string }) { + return function checkSession(req: any, res: any, next: Next) { + req.user = sessions.check(req.get('cookie')); + next(); + }; +} + +export function createRelay(target: string) { + return function relayUpstream(req: any, res: any) { + res.send(target); + }; +} + +export function mountSite(app: any, sessions: { check(token: string): string }) { + app.use(requireSession(sessions)); + app.use('/site', createRelay('http://site.internal')); +} From 29449d6403ec257c867e13e1f6936a85781a7405 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Wed, 30 Sep 2026 02:38:00 -0700 Subject: [PATCH 2/3] typescript: join a message key's sender to its handler (remote_edge) A producer that emits on a key (a Nest microservices client's emit/send, a kafkajs producer's send({ topic })) was not joined to the handler registered on the same key (@EventPattern / @MessagePattern, a kafkajs consumer's subscribe), so path stopped at the send and impact of a handler named no sender. destinations.dl gains a messaging section. Both ends are recognised by the receiver's DECLARED type and the package it is imported from (knobs.dl, ts_msg_client_type), never by the method name, so emit on a Node event emitter is not a send. The key is read the way a route is: a literal, a const, a const-object member (now also through `as const`), local or imported. One wrapper hop is bound: a private send(pattern, x) called with the key joins from its caller. A decorator's Transport argument names the broker; an end that names none joins any. One-sided keys are reported as remote_unserved / remote_unsent, an unreadable key as remote_undetermined. remote_unsent is judged by destination, so a handler reached by a send that names no broker is not also reported unsent. Checked: new CLI case cross-process-message (emit on an imported const-object member, send on a const, a key through a wrapper, kafkajs send/subscribe, controls: an EventEmitter emit and a handler nobody sends to) fails 5/7 before and passes 7/7; typescript case suite 212/212, engine suite 99/0. On a monorepo with three services: probes 44 -> 47 of 74, no regression, call edges unchanged, 5 amqp + 2 kafka remote edges added. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../engine/config-resolution/knobs.dl | 50 ++++++ .../engine/framework-behavior/destinations.dl | 143 +++++++++++++++++- .../cross-process-message/case.json | 27 ++++ .../src/consumer/items.controller.ts | 23 +++ .../src/producer/microservices.d.ts | 13 ++ .../src/producer/producer.test.ts | 7 + .../src/producer/producer.ts | 39 +++++ .../src/shared/package.json | 1 + .../src/shared/topics.ts | 4 + 9 files changed, 303 insertions(+), 4 deletions(-) create mode 100644 tests/cases/typescript/cross-process-message/case.json create mode 100644 tests/cases/typescript/cross-process-message/src/consumer/items.controller.ts create mode 100644 tests/cases/typescript/cross-process-message/src/producer/microservices.d.ts create mode 100644 tests/cases/typescript/cross-process-message/src/producer/producer.test.ts create mode 100644 tests/cases/typescript/cross-process-message/src/producer/producer.ts create mode 100644 tests/cases/typescript/cross-process-message/src/shared/package.json create mode 100644 tests/cases/typescript/cross-process-message/src/shared/topics.ts diff --git a/graph/typescript/engine/config-resolution/knobs.dl b/graph/typescript/engine/config-resolution/knobs.dl index 3e093b7a..c9064f72 100644 --- a/graph/typescript/engine/config-resolution/knobs.dl +++ b/graph/typescript/engine/config-resolution/knobs.dl @@ -202,3 +202,53 @@ ts_http_client_module("axios"). ts_http_client_factory("create"). .decl ts_http_base_url_key(c0:symbol) ts_http_base_url_key("baseURL"). + +// ── MESSAGING: a key both ends spell (a topic, a queue, a message pattern) ── +// ts_msg_client_type(Specifier, TypeName, Role): a receiver DECLARED as TypeName, +// imported from Specifier, sends or subscribes. Matched on the declared type and the +// package it comes from, never on the method name alone: `emit` on a Node event +// emitter, `send` on an HTTP reply or a socket, `subscribe` on an observable are none +// of these. +// nest_client `.emit(key, data)` / `.send(key, data)` +// kafka_producer `.send({ topic, messages })` +// kafka_consumer `.subscribe({ topic })` / `({ topics: [...] })` +.decl ts_msg_client_type(c0:symbol, c1:symbol, c2:symbol) +ts_msg_client_type("@nestjs/microservices", "ClientProxy", "nest_client"). +ts_msg_client_type("@nestjs/microservices", "ClientKafka", "nest_client"). +ts_msg_client_type("@nestjs/microservices", "ClientRMQ", "nest_client"). +ts_msg_client_type("@nestjs/microservices", "ClientNats", "nest_client"). +ts_msg_client_type("@nestjs/microservices", "ClientRedis", "nest_client"). +ts_msg_client_type("@nestjs/microservices", "ClientMqtt", "nest_client"). +ts_msg_client_type("@nestjs/microservices", "ClientTCP", "nest_client"). +ts_msg_client_type("kafkajs", "Producer", "kafka_producer"). +ts_msg_client_type("kafkajs", "Consumer", "kafka_consumer"). +// ts_msg_send(Role, Method, Transport): the call that sends; the key is argument 0, +// or its `topic` property when the argument is a record (ts_msg_record_key) +.decl ts_msg_send(c0:symbol, c1:symbol, c2:symbol) +ts_msg_send("nest_client", "emit", "message"). +ts_msg_send("nest_client", "send", "message"). +ts_msg_send("kafka_producer", "send", "kafka"). +.decl ts_msg_subscribe(c0:symbol, c1:symbol, c2:symbol) +ts_msg_subscribe("kafka_consumer", "subscribe", "kafka"). +// the property of a record argument that names the key, and the one naming several +.decl ts_msg_record_key(c0:symbol) +ts_msg_record_key("topic"). +.decl ts_msg_record_keys(c0:symbol) +ts_msg_record_keys("topics"). +// ts_msg_handler_decorator(Name): a method decorator whose argument 0 is the key the +// method handles (`@EventPattern(TOPICS.x)`, `@MessagePattern('orders.place')`) +.decl ts_msg_handler_decorator(c0:symbol) +ts_msg_handler_decorator("EventPattern"). +ts_msg_handler_decorator("MessagePattern"). +// ts_msg_transport_member(Written, Transport): the decorator's optional argument 1 +.decl ts_msg_transport_member(c0:symbol, c1:symbol) +ts_msg_transport_member("Transport.KAFKA", "kafka"). +ts_msg_transport_member("Transport.RMQ", "amqp"). +ts_msg_transport_member("Transport.NATS", "nats"). +ts_msg_transport_member("Transport.REDIS", "redis"). +ts_msg_transport_member("Transport.MQTT", "mqtt"). +ts_msg_transport_member("Transport.TCP", "tcp"). +// the transport of an end that does not say which broker it uses (a Nest client is +// bound to one in module configuration): it joins an end of any transport +.decl ts_remote_transport_message(c0:symbol) +ts_remote_transport_message("message"). diff --git a/graph/typescript/engine/framework-behavior/destinations.dl b/graph/typescript/engine/framework-behavior/destinations.dl index 7370082c..35df1c87 100644 --- a/graph/typescript/engine/framework-behavior/destinations.dl +++ b/graph/typescript/engine/framework-behavior/destinations.dl @@ -29,13 +29,18 @@ // literal, a template, a `+` concatenation, a constant (in this module or imported), // and a property of a const object literal (a config module). // +// COVERED: MESSAGING (section 6). A key (a topic, a queue, a message pattern) sent by +// a Nest microservices client (`emit` / `send`) or a kafkajs producer, joined to a +// handler registered on the same key (`@EventPattern` / `@MessagePattern`, a kafkajs +// consumer's `subscribe`), the key read as a route is; one wrapper hop is bound. +// // NOT COVERED YET, stated rather than left to be discovered: // - a router mounted under a prefix (`app.use('/api', router)`): the routes of the // router are compared without it; // - an object-form registration (`fastify.route({ method, url, handler })`); // - a URL built by a wrapper the client calls with the path (the C# rules bind one // hop of that; here the wrapper's own send is reported undetermined); -// - messaging (a topic, a queue) and gRPC. +// - gRPC. // ============================================================================ // remote_edge, remote_unserved, remote_unsent and remote_undetermined are declared in @@ -81,11 +86,17 @@ rd_demand(val) :- rd_prop_value(_, val). rd_demand(c) :- rd_demand(e), expr_kind("client", k, _, e), expr_kind_is_transparent(k), expr_child("client", e, _, _, c). +// the object literal a const is initialised with, through `as const` / `satisfies T` +.decl rd_init_obj(c0:symbol, c1:symbol) +rd_init_obj(v, o) :- var_initializer("client", _, o, v), expr_kind("client", "OBJECT_LITERAL", _, o). +rd_init_obj(v, o) :- var_initializer("client", _, i, v), expr_kind("client", k, _, i), expr_kind_is_transparent(k), + expr_child("client", i, _, _, o), expr_kind("client", "OBJECT_LITERAL", _, o). + // `config.reportsUrl`, where `config` is a const initialised with an object literal rd_prop_value(e, val) :- rd_demand(e), expr_kind("client", "PROPERTY_ACCESS", _, e), expr_child("client", e, "RECEIVER", _, r), expr_child("client", e, "PROPERTY_NAME", _, pn), expr_literal_value("client", key, pn), - rd_denotes_var(r, v), var_initializer("client", _, o, v), rd_obj_prop(o, key, val). + rd_denotes_var(r, v), rd_init_obj(v, o), rd_obj_prop(o, key, val). .decl rd_is_str(c0:symbol) .decl rd_is_tmpl(c0:symbol) @@ -411,5 +422,129 @@ remote_undetermined(from, tr, "unresolved_destination") :- ts_remote_transport_h remote_edge(from, to, tr, d, conf) :- remote_edge_via(_, from, to, tr, d, conf). // judged per SEND SITE: a site that linked is not also reported under another spelling remote_unserved(m, tr, d) :- remote_send_at(e, m, tr, d), !remote_edge_via(e, _, _, _, _, _). -// a handler nothing here sends to: its client is in another repository -remote_unsent(m, tr, d) :- remote_serves(m, tr, d), !remote_edge(_, m, tr, d, _). +// a handler nothing here sends to: its client is in another repository. Judged by +// destination alone: a message sent without naming its broker (transport "message") +// is reported under the handler's broker, and still serves it. +.decl remote_reached_at(c0:symbol, c1:symbol) +remote_reached_at(m, d) :- remote_edge(_, m, _, d, _). +remote_unsent(m, tr, d) :- remote_serves(m, tr, d), !remote_reached_at(m, d). + +// ───────────────────────────────────────────────────────────────────────────── +// 6. MESSAGING: a key both ends spell +// ───────────────────────────────────────────────────────────────────────────── +// A producer sends on a KEY (a topic, a queue, a message pattern) and a handler is +// registered on one; neither calls the other. The key is read exactly as a route is +// (section 1): a literal, a const, a const-object member, imported or local. Both +// ends are recognised by what the RECEIVER is declared as, a type imported from the +// messaging package (config-resolution/knobs.dl, ts_msg_client_type), never by the +// method name: `emit` on a Node event emitter is not a broker send. +// +// NOT COVERED YET: a pattern object (`@MessagePattern({ cmd: 'x' })`), a key held in a +// field set at run time (`this.topic`), a receiver whose type is only inferred from a +// factory call in a package outside the graph (`kafka.producer()` with no annotation). + +// the receivers of a call that could be a send or a subscribe +.decl rm_candidate_recv(c0:symbol) +rm_candidate_recv(q) :- call_site("client", "METHOD_CALL", cn, _, q, _, _), ts_msg_send(_, cn, _). +rm_candidate_recv(q) :- call_site("client", "METHOD_CALL", cn, _, q, _, _), ts_msg_subscribe(_, cn, _). +// `this.client`: the field it names, a parameter property included +.decl rm_this_field(c0:symbol, c1:symbol) +rm_this_field(q, f) :- rm_candidate_recv(q), property_access_name(q, n), + expr_child("client", q, "RECEIVER", _, t), expr_type(t, _, tt), field_in_scope(tt, n, "false", f). +// the type reference the receiver is DECLARED with +.decl rm_recv_ref(c0:symbol, c1:symbol) +rm_recv_ref(q, r) :- rm_candidate_recv(q), expr_referenced("client", "PARAMETER", p, q), param_type_ref("client", r, p). +rm_recv_ref(q, r) :- rm_candidate_recv(q), expr_referenced("client", "VARIABLE", v, q), var_type_ref("client", r, v). +rm_recv_ref(q, r) :- rm_this_field(q, f), field_type_ref("client", r, f). +rm_recv_ref(q, r) :- rm_this_field(q, f), param_declares_field("client", f, p), param_type_ref("client", r, p). +// the package a type name is imported from, under the name it exports +// (`import type { ClientProxy as Client } from '@nestjs/microservices'`) +.decl rm_ref_import(c0:symbol, c1:symbol, c2:symbol) +rm_ref_import(r, spec, orig) :- rm_recv_ref(_, r), type_ref("client", _, _, tn, _, _, _, r), type_ref_module("client", m, r), + import_binding("client", _, tn, orig, m, ih), orig != "", rd_import_path(ih, spec). +rm_ref_import(r, spec, tn) :- rm_recv_ref(_, r), type_ref("client", _, _, tn, _, _, _, r), type_ref_module("client", m, r), + import_binding("client", _, tn, "", m, ih), rd_import_path(ih, spec). +.decl rm_recv_role(c0:symbol, c1:symbol) +rm_recv_role(q, role) :- rm_recv_ref(q, r), rm_ref_import(r, spec, name), ts_msg_client_type(spec, name, role). + +// (call, role, transport) +.decl rm_send_call(c0:symbol, c1:symbol, c2:symbol) +rm_send_call(ce, role, tr) :- call_site("client", "METHOD_CALL", cn, _, q, ce, _), ts_msg_send(role, cn, tr), + rm_recv_role(q, role). +.decl rm_sub_call(c0:symbol, c1:symbol, c2:symbol) +rm_sub_call(ce, role, tr) :- call_site("client", "METHOD_CALL", cn, _, q, ce, _), ts_msg_subscribe(role, cn, tr), + rm_recv_role(q, role). + +// the expression holding the key: argument 0, or the `topic` of a record argument, or +// each element of its `topics` +.decl rm_key_arg(c0:symbol, c1:symbol) +rm_key_arg(ce, a) :- rm_send_call(ce, "nest_client", _), expr_child("client", ce, "ARGUMENT", "0", a). +rm_key_arg(ce, v) :- rm_send_call(ce, role, _), role != "nest_client", expr_child("client", ce, "ARGUMENT", "0", o), + rd_obj_prop(o, k, v), ts_msg_record_key(k). +rm_key_arg(ce, v) :- rm_sub_call(ce, _, _), expr_child("client", ce, "ARGUMENT", "0", o), + rd_obj_prop(o, k, v), ts_msg_record_key(k). +rm_key_arg(ce, el) :- rm_sub_call(ce, _, _), expr_child("client", ce, "ARGUMENT", "0", o), + rd_obj_prop(o, k, arr), ts_msg_record_keys(k), expr_kind("client", "ARRAY_LITERAL", _, arr), + expr_child("client", arr, _, _, el). +rd_demand(a) :- rm_key_arg(_, a). +// a key is a value with no computed part (a key expression of either end) +.decl rm_key_expr(c0:symbol) +rm_key_expr(a) :- rm_key_arg(_, a). +rm_key_expr(a) :- rm_handler_key_arg(_, _, a). +.decl rm_key(c0:symbol, c1:symbol) +rm_key(e, k) :- rm_key_expr(e), rd_val(e, k), k != "", !contains("{}", k). + +// ONE WRAPPER HOP: the key is a parameter of the method that sends, and each call of +// that method names it: `place() { return this.send(CMD.place, x); }` over +// `private send(pattern, x) { return this.proxy.send(pattern, x); }`. The send is then +// judged at the CALL of the wrapper, from the method that makes it. +.decl rm_key_param(c0:symbol, c1:symbol, c2:symbol) +rm_key_param(ce, w, pos) :- rm_key_arg(ce, a), expr_referenced("client", "PARAMETER", p, a), + param_decl("client", _, pos, _, w, p), expr_enclosing_method(ce, w). +.decl rm_wrap_arg(c0:symbol, c1:symbol, c2:symbol) +rm_wrap_arg(ce, oc, a) :- rm_key_param(ce, w, pos), call_chain_edge(oc, _, "-", w, "client", _, _), + expr_child("client", oc, "ARGUMENT", pos, a). +rd_demand(a) :- rm_wrap_arg(_, _, a). + +// (site, sending method, transport, key) +.decl rm_send(c0:symbol, c1:symbol, c2:symbol, c3:symbol) +rm_send(ce, from, tr, k) :- rm_send_call(ce, _, tr), rm_key_arg(ce, a), rm_key(a, k), expr_enclosing_method(ce, from). +rm_send(oc, from, tr, k) :- rm_send_call(ce, _, tr), rm_wrap_arg(ce, oc, a), rd_val(a, k), k != "", !contains("{}", k), + expr_enclosing_method(oc, from). + +// ── the handler end ───────────────────────────────────────────────────────── +// a decorated method: `@EventPattern(key, Transport.KAFKA)`; the argument's expression +// is read like any other +.decl rm_handler_key_arg(c0:symbol, c1:symbol, c2:symbol) +rm_handler_key_arg(m, d, a) :- annotation_on("client", n, _, "METHOD_DECLARATION", m, d), ts_msg_handler_decorator(n), + ts_decorator_argument(_, _, _, "0", d, _, _, _, _, _, a, _, _), a != "". +rd_demand(a) :- rm_handler_key_arg(_, _, a). +.decl rm_handler_tr(c0:symbol, c1:symbol) +rm_handler_tr(d, tr) :- rm_handler_key_arg(_, d, _), annotation_arg("client", _, v, _, "1", _, d, _), + ts_msg_transport_member(v, tr). +.decl rm_handler_has_tr(c0:symbol) +rm_handler_has_tr(d) :- rm_handler_tr(d, _). +// (handler, transport, key) +.decl rm_serve(c0:symbol, c1:symbol, c2:symbol) +rm_serve(m, tr, k) :- rm_handler_key_arg(m, d, a), rm_key(a, k), rm_handler_tr(d, tr). +rm_serve(m, tr, k) :- rm_handler_key_arg(m, d, a), rm_key(a, k), !rm_handler_has_tr(d), ts_remote_transport_message(tr). +// a subscription: the function that subscribes is where the messages arrive +rm_serve(m, tr, k) :- rm_sub_call(ce, _, tr), rm_key_arg(ce, a), rm_key(a, k), expr_enclosing_method(ce, m). + +// ── the join ──────────────────────────────────────────────────────────────── +// Same key; the transports agree, or one end does not say. The edge is reported under +// the end that names its broker. +.decl rm_edge_tr(c0:symbol, c1:symbol, c2:symbol) +rm_edge_tr(a, a, a) :- rm_send(_, _, a, _). +rm_edge_tr(a, b, b) :- rm_send(_, _, a, _), rm_serve(_, b, _), ts_remote_transport_message(a), a != b. +rm_edge_tr(a, b, a) :- rm_send(_, _, a, _), rm_serve(_, b, _), ts_remote_transport_message(b), a != b. +remote_edge_via(e, from, to, tr, k, conf) :- rm_send(e, from, st, k), rm_serve(to, ht, k), rm_edge_tr(st, ht, tr), + from != to, ts_remote_confidence_exact(conf). +remote_serves(m, tr, k) :- rm_serve(m, tr, k). +remote_send_at(e, from, tr, k) :- rm_send(e, from, tr, k). +// a send whose key cannot be read, and that no caller names either +.decl rm_send_keyed(c0:symbol) +rm_send_keyed(ce) :- rm_send(ce, _, _, _). +rm_send_keyed(ce) :- rm_wrap_arg(ce, oc, _), rm_send(oc, _, _, _). +remote_undetermined(from, tr, "unresolved_destination") :- rm_send_call(ce, _, tr), !rm_send_keyed(ce), + expr_enclosing_method(ce, from). diff --git a/tests/cases/typescript/cross-process-message/case.json b/tests/cases/typescript/cross-process-message/case.json new file mode 100644 index 00000000..f9b9ad41 --- /dev/null +++ b/tests/cases/typescript/cross-process-message/case.json @@ -0,0 +1,27 @@ +{"lang": "typescript", "src": "src", + "checks": [ + {"why": "a handler registered on a message key is depended on by the method that emits on the same key, read through an imported const-object member: a remote hop naming the transport and the key", + "run": ["impact", "ItemsController.onCreated"], + "want": ["[remote] ItemPublisher.announce", "across a process boundary (kafka) at item.created.v1"], + "avoid": ["LocalBus.fire", "ItemPublisher.shout"]}, + {"why": "path joins a request/reply send on a plain const to the handler of the same pattern", + "run": ["path", "ItemPublisher.place", "ItemsController.placeItem"], + "want": ["connected across a process: ItemPublisher.place → ItemsController.placeItem", "[amqp] at item.place (exact)"], + "expect_error": true}, + {"why": "a key passed into a private wrapper that sends it: the edge starts at the caller that names the key", + "run": ["path", "CommandClient.cancel", "ItemsController.cancelItem"], + "want": ["connected across a process: CommandClient.cancel → ItemsController.cancelItem", "at item.cancel (exact)"], + "expect_error": true}, + {"why": "kafkajs: the topic of a producer record joins the function that subscribes to it", + "run": ["impact", "listenRemovals"], + "want": ["[remote] RawProducer.removed", "at item.removed.v1"]}, + {"why": "CONTROL: a Node event emitter's emit is not a broker send, even on the same key", + "run": ["impact", "LocalBus.fire"], + "avoid": ["[remote]", "onCreated"]}, + {"why": "CONTROL: a handler on a key nothing here sends to has no sender", + "run": ["impact", "ItemsController.unused"], + "avoid": ["[remote]"]}, + {"why": "test-impact crosses the key: the test that drives the producer is selected on the remote rung", + "run": ["impact", "ItemsController.onCreated", "--tests-only"], + "want": ["producer.test.ts"]} + ]} diff --git a/tests/cases/typescript/cross-process-message/src/consumer/items.controller.ts b/tests/cases/typescript/cross-process-message/src/consumer/items.controller.ts new file mode 100644 index 00000000..d601de60 --- /dev/null +++ b/tests/cases/typescript/cross-process-message/src/consumer/items.controller.ts @@ -0,0 +1,23 @@ +import { EventPattern, MessagePattern, Transport } from '@nestjs/microservices'; +import type { Consumer } from 'kafkajs'; +import { CMD, COMMANDS, TOPICS } from '@acme/shared'; + +export class ItemsController { + @EventPattern(TOPICS.created, Transport.KAFKA) + onCreated(event: unknown) { return event; } + + @MessagePattern(CMD, Transport.RMQ) + placeItem(command: unknown) { return command; } + + @MessagePattern(COMMANDS.cancel) + cancelItem(command: unknown) { return command; } + + // no sender here: an unsent handler + @EventPattern('other.topic') + unused(event: unknown) { return event; } +} + +export async function listenRemovals(consumer: Consumer) { + await consumer.subscribe({ topic: TOPICS.removed }); + await consumer.run({ eachMessage: async () => undefined }); +} diff --git a/tests/cases/typescript/cross-process-message/src/producer/microservices.d.ts b/tests/cases/typescript/cross-process-message/src/producer/microservices.d.ts new file mode 100644 index 00000000..adb789e9 --- /dev/null +++ b/tests/cases/typescript/cross-process-message/src/producer/microservices.d.ts @@ -0,0 +1,13 @@ +declare module '@nestjs/microservices' { + export class ClientProxy { + emit(pattern: unknown, data: unknown): unknown; + send(pattern: unknown, data: unknown): unknown; + } + export enum Transport { KAFKA, RMQ } + export function EventPattern(pattern: unknown, transport?: unknown): MethodDecorator; + export function MessagePattern(pattern: unknown, transport?: unknown): MethodDecorator; +} +declare module 'kafkajs' { + export interface Producer { send(record: { topic: string; messages: unknown[] }): Promise; } + export interface Consumer { subscribe(s: { topic?: string; topics?: string[] }): Promise; run(c: unknown): Promise; } +} diff --git a/tests/cases/typescript/cross-process-message/src/producer/producer.test.ts b/tests/cases/typescript/cross-process-message/src/producer/producer.test.ts new file mode 100644 index 00000000..0c25016a --- /dev/null +++ b/tests/cases/typescript/cross-process-message/src/producer/producer.test.ts @@ -0,0 +1,7 @@ +import { it, expect } from 'vitest'; +import { ItemPublisher } from './producer'; + +it('announces an item', () => { + const publisher = new ItemPublisher({ emit: () => 1, send: () => 1 } as never); + expect(publisher.announce('a')).toBeDefined(); +}); diff --git a/tests/cases/typescript/cross-process-message/src/producer/producer.ts b/tests/cases/typescript/cross-process-message/src/producer/producer.ts new file mode 100644 index 00000000..60c09c98 --- /dev/null +++ b/tests/cases/typescript/cross-process-message/src/producer/producer.ts @@ -0,0 +1,39 @@ +import { EventEmitter } from 'events'; +import type { ClientProxy } from '@nestjs/microservices'; +import type { Producer } from 'kafkajs'; +import { CMD, COMMANDS, TOPICS } from '../shared/topics'; + +export class ItemPublisher { + constructor(private readonly client: ClientProxy) {} + + // emit on a const-object member: joins the handler that spells the same member + announce(id: string) { return this.client.emit(TOPICS.created, { id }); } + + // send on a plain const: joins the request/reply handler + place(id: string) { return this.client.send(CMD, { id }); } + + // a key nothing here handles: an unserved send + shout() { return this.client.emit('nobody.listens', {}); } +} + +export class CommandClient { + constructor(private readonly proxy: ClientProxy) {} + + // the key is the wrapper's argument: the send joins from here + cancel(id: string) { return this.dispatch(COMMANDS.cancel, { id }); } + + private dispatch(pattern: string, body: unknown) { return this.proxy.send(pattern, body); } +} + +export class RawProducer { + constructor(private readonly producer: Producer) {} + + // kafkajs: the topic is a property of the record + removed(id: string) { return this.producer.send({ topic: TOPICS.removed, messages: [{ value: id }] }); } +} + +// CONTROL: a Node event emitter's emit is not a broker send, however its key reads +export class LocalBus { + private readonly emitter = new EventEmitter(); + fire() { this.emitter.emit(TOPICS.created, {}); } +} diff --git a/tests/cases/typescript/cross-process-message/src/shared/package.json b/tests/cases/typescript/cross-process-message/src/shared/package.json new file mode 100644 index 00000000..6a3a4605 --- /dev/null +++ b/tests/cases/typescript/cross-process-message/src/shared/package.json @@ -0,0 +1 @@ +{ "name": "@acme/shared", "version": "0.0.0", "main": "./dist/topics.js", "types": "./dist/topics.d.ts" } diff --git a/tests/cases/typescript/cross-process-message/src/shared/topics.ts b/tests/cases/typescript/cross-process-message/src/shared/topics.ts new file mode 100644 index 00000000..dd3684d2 --- /dev/null +++ b/tests/cases/typescript/cross-process-message/src/shared/topics.ts @@ -0,0 +1,4 @@ +// The keys both ends spell: a const-object member, and a plain const. +export const TOPICS = { created: 'item.created.v1', removed: 'item.removed.v1' } as const; +export const CMD = 'item.place'; +export const COMMANDS = { cancel: 'item.cancel' } as const; From ad56060fed957481343ac6702e3dba2b187ca216 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Wed, 30 Sep 2026 03:08:42 -0700 Subject: [PATCH 3/3] javascript: a model made by an ORM factory carries its schema's statics `connection.model(name, schema)` builds the model class at runtime, so a call such as `this.Doc.findLive()` on it had an untyped receiver and was only matched by name, with same-named methods elsewhere as equal candidates. The schema argument now gives the call a value: a `new Schema(...)` reached through an import of the ORM package (named, default, namespace, require, or a local destructure) is tracked, and whatever its `statics` holds (member writes, `static('n', f)`, `static({...})`, the `statics` option) is a member of every model made from it; its `methods` are members of `new Model(...)`. The package's own members stay receiver_untyped as before. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../callee-resolution.dl | 9 ++- .../engine/resolution/frameworks.dl | 64 +++++++++++++++++++ graph/javascript/souffle/decls_all.dl | 6 ++ .../cases/74-orm-model-factory/src/models.js | 32 ++++++++++ .../cases/74-orm-model-factory/src/repo.js | 31 +++++++++ .../expected/74-orm-model-factory.diag | 17 +++++ .../expected/74-orm-model-factory.edges | 36 +++++++++++ .../expected/74-orm-model-factory.oracle | 12 ++++ 8 files changed, 204 insertions(+), 3 deletions(-) create mode 100644 graph/test/javascript/cases/74-orm-model-factory/src/models.js create mode 100644 graph/test/javascript/cases/74-orm-model-factory/src/repo.js create mode 100644 graph/test/javascript/expected/74-orm-model-factory.diag create mode 100644 graph/test/javascript/expected/74-orm-model-factory.edges create mode 100644 graph/test/javascript/expected/74-orm-model-factory.oracle diff --git a/graph/javascript/engine/expression-resolution/callee-resolution.dl b/graph/javascript/engine/expression-resolution/callee-resolution.dl index 946d7cb3..a59664b2 100644 --- a/graph/javascript/engine/expression-resolution/callee-resolution.dl +++ b/graph/javascript/engine/expression-resolution/callee-resolution.dl @@ -133,7 +133,10 @@ call_target_is_implicit_ctor(ce) :- call_site(_, "SUPER_CALL", _, _, _, em, ce, // Splits the unresolved population into "the receiver is untyped" (a staging gap // or a genuinely dynamic value) and "the receiver is known and has no such // member" (an engine or parser defect — the rows worth reading first). +// A value a package model builds (frameworks.dl, package_model_kind) carries only the +// members the project wrote onto it; the rest are the uninstalled package's, so a +// site that finds nothing on it is as untyped as it was before the model existed. receiver_value_known(ce) :- call_site(_, _, _, "SYNTACTIC", _, _, ce, _, _), - expr_child(_, ce, "RECEIVER", _, r), expr_value(r, _, _). -callee_value_known(ce) :- callee_value(ce, _, _). -callee_value_known(ce) :- new_callee_value(ce, _, _). + expr_child(_, ce, "RECEIVER", _, r), expr_value(r, k, _), !package_model_kind(k). +callee_value_known(ce) :- callee_value(ce, k, _), !package_model_kind(k). +callee_value_known(ce) :- new_callee_value(ce, k, _), !package_model_kind(k). diff --git a/graph/javascript/engine/resolution/frameworks.dl b/graph/javascript/engine/resolution/frameworks.dl index dc932573..a7361097 100644 --- a/graph/javascript/engine/resolution/frameworks.dl +++ b/graph/javascript/engine/resolution/frameworks.dl @@ -153,6 +153,70 @@ express_error_value(k, i) :- expr_root("client", "THROW", e), expr_value(e, k, i express_error_value(k, i) :- call_site("client", _, _, _, _, _, ce, _, _), callee_value(ce, "func", m), model_express_value("next", "func", m), call_arg(ce, 0, arg), expr_value(arg, k, i). +// ── ORM model factories: a model carries its schema's statics ─────────────── +// `const s = new Schema({...}); s.statics.findLive = function () {...}; +// const Doc = connection.model('Doc', s); Doc.findLive()` — the package builds the +// model class at runtime from the schema, so nothing in the tree declares it, and +// `connection` is usually a parameter nobody visible passes. The SCHEMA is the value +// that says what the model has: whatever `schema.statics` holds (member writes, +// `schema.static('n', f)`, `schema.static({ n: f })`, `new Schema(def, { statics })`) +// is a member of every model made from it, and whatever `schema.methods` holds is a +// member of every document `new Model(...)` makes. The factory call is recognised by +// its schema ARGUMENT, not by its receiver. +// +// A schema is a `new` of the package's `Schema`, reached through an import of a +// modelled package — named, default, namespace or require, directly or through a +// local alias or destructure (`const { Schema } = mongoose`). The package need not be +// installed. The five values are all keyed by the `new Schema(...)` site, so every +// model of one schema shares its members. Every other member of them (`find`, +// `create`, `save`) is the package's and stays unknown: package_model_kind keeps +// those sites receiver_untyped (callee-resolution.dl), as they were. +orm_schema_package("mongoose"). +orm_ref(e, "") :- expr_kind(_, "MODULE_EDGE_CALL", _, e), expr_module_edge(_, imp, e), + import_decl(_, spec, _, _, _, _, _, _, imp), orm_schema_package(spec). +orm_ref(e, "") :- expr_kind(_, "IDENTIFIER", _, e), expr_binding(_, v, e), var_import(_, imp, v), + import_decl(_, spec, _, bf, _, _, _, _, imp), import_binds_whole_module(bf), orm_schema_package(spec). +orm_ref(e, n) :- expr_kind(_, "IDENTIFIER", _, e), expr_binding(_, v, e), var_import(_, imp, v), + import_decl(_, spec, _, bf, n, _, _, _, imp), import_binding_is_named(bf), n != "", orm_schema_package(spec). +orm_ref(e, n) :- expr_kind(_, "IDENTIFIER", _, e), expr_binding(_, v, e), !var_import(_, _, v), + var_init(_, _, init, v), var_binding_path(_, n, v), orm_ref(init, ""). +orm_ref(e, n) :- expr_kind(_, "IDENTIFIER", _, e), expr_binding(_, v, e), !var_import(_, _, v), + var_init(_, _, init, v), !var_binding_path(_, _, v), orm_ref(init, n). +orm_ref(e, n) :- expr_kind(_, k, _, e), access_kind_reads_member(k), expr_name(_, n, e), n != "", + expr_child(_, e, "ACCESS_TARGET", _, r), orm_ref(r, ""). +orm_schema_site(ne) :- call_site(_, "CONSTRUCTOR_CALL", _, _, _, _, ne, _, _), + expr_child(_, ne, "CALLEE", _, c), orm_ref(c, "Schema"). +orm_schema_site(ne) :- call_site(_, "CONSTRUCTOR_CALL", "Schema", "SYNTACTIC", _, _, ne, _, _), + expr_child(_, ne, "RECEIVER", _, r), orm_ref(r, ""). +package_model_kind("orm_schema"). +package_model_kind("orm_statics"). +package_model_kind("orm_methods"). +package_model_kind("orm_model"). +package_model_kind("orm_doc"). +orm_part("statics", "orm_statics", "static"). +orm_part("methods", "orm_methods", "method"). +expr_value(ne, "orm_schema", ne) :- orm_schema_site(ne). +prop_value("orm_schema", s, part, pk, s) :- orm_schema_site(s), orm_part(part, pk, _). +prop_value("orm_schema", s, part, k, i) :- orm_schema_site(s), orm_part(part, _, _), + call_arg(s, 1, o), expr_value(o, "obj", l), prop_value("obj", l, part, k, i). +member_write(pk, s, n, k, i) :- call_site(_, ck, cn, "SYNTACTIC", _, _, ce, _, _), call_kind_is_member_form(ck), + orm_part(_, pk, cn), expr_child(_, ce, "RECEIVER", _, r), expr_value(r, "orm_schema", s), + call_arg(ce, 0, a), expr_value(a, "str", n), call_arg(ce, 1, f), expr_value(f, k, i). +member_write(pk, s, n, k, i) :- call_site(_, ck, cn, "SYNTACTIC", _, _, ce, _, _), call_kind_is_member_form(ck), + orm_part(_, pk, cn), expr_child(_, ce, "RECEIVER", _, r), expr_value(r, "orm_schema", s), + call_arg(ce, 0, a), expr_value(a, "obj", l), prop_value("obj", l, n, k, i). +// `x.model(name, schema)` / `model(name, schema)`: the model. `new Model(...)`: a document. +orm_model_call(ce, s) :- call_site(_, ck, "model", _, _, _, ce, _, _), + (call_kind_is_member_form(ck) ; call_kind_is_callee_form(ck)), + call_arg(ce, 1, a), expr_value(a, "orm_schema", s). +expr_value(ce, "orm_model", s) :- orm_model_call(ce, s). +expr_value(ne, "orm_doc", s) :- expr_kind(_, "NEW", _, ne), new_callee_value(ne, "orm_model", s). +prop_value("orm_model", s, n, k, i) :- prop_value("orm_schema", s, "statics", k0, i0), prop_value(k0, i0, n, k, i). +prop_value("orm_doc", s, n, k, i) :- prop_value("orm_schema", s, "methods", k0, i0), prop_value(k0, i0, n, k, i). +// A static runs with the model as `this`, a method with the document. +this_value(m, "orm_model", s) :- prop_value("orm_model", s, _, "func", m), method_this_binding(_, "DYNAMIC", m), !method_owner_type(m, _). +this_value(m, "orm_doc", s) :- prop_value("orm_doc", s, _, "func", m), method_this_binding(_, "DYNAMIC", m), !method_owner_type(m, _). + // ── property descriptors (#489) ───────────────────────────────────────────── // `Object.defineProperty(o, 'm', { value: f })`, `Object.defineProperties(o, { m: {...} })`, // `Object.create(proto, { m: {...} })`, and the export form a bundler-compiled CommonJS diff --git a/graph/javascript/souffle/decls_all.dl b/graph/javascript/souffle/decls_all.dl index d02c17d9..7f73c459 100644 --- a/graph/javascript/souffle/decls_all.dl +++ b/graph/javascript/souffle/decls_all.dl @@ -503,6 +503,12 @@ .decl express_error_slot(c0:symbol, c1:symbol) .decl express_param_slot(c0:symbol, c1:symbol) .decl express_error_value(c0:symbol, c1:symbol) +.decl orm_schema_package(c0:symbol) +.decl orm_ref(c0:symbol, c1:symbol) +.decl orm_schema_site(c0:symbol) +.decl package_model_kind(c0:symbol) +.decl orm_part(c0:symbol, c1:symbol, c2:symbol) +.decl orm_model_call(c0:symbol, c1:symbol) .decl heritage_text(c0:symbol, c1:symbol, c2:symbol) .decl type_own_getter(c0:symbol, c1:symbol, c2:symbol) .decl type_own_static_getter(c0:symbol, c1:symbol, c2:symbol) diff --git a/graph/test/javascript/cases/74-orm-model-factory/src/models.js b/graph/test/javascript/cases/74-orm-model-factory/src/models.js new file mode 100644 index 00000000..c5b18612 --- /dev/null +++ b/graph/test/javascript/cases/74-orm-model-factory/src/models.js @@ -0,0 +1,32 @@ +'use strict'; +// A model made by an ORM factory, `connection.model(name, schema)`, is a class the +// package builds at runtime from the schema: its statics are the functions written +// onto `schema.statics` (or handed to `schema.static(...)`), and a document it +// constructs has the schema's methods. The package is not installed, so nothing in +// the tree declares the model; the schema argument is what carries its members. +const mongoose = require('mongoose'); +const { Schema } = mongoose; + +const docSchema = new Schema({ title: String }); +docSchema.statics.findLive = function () { return this.find({}); }; +docSchema.static('claim', function () { return this.findLive(); }); +docSchema.static({ purge() { return 0; } }); +docSchema.methods.toRecord = function () { return this.title; }; +docSchema.method('touch', function () { return this.toRecord(); }); + +// The options form, on the package's namespace. +const jobSchema = new mongoose.Schema({}, { statics: { due() { return 1; } } }); + +// Control: a schema-like object from a package that is not modelled stays unknown. +const { Schema: OtherSchema } = require('other-orm'); +const otherSchema = new OtherSchema({}); +otherSchema.statics.findLive = function () { return 2; }; + +function registerModels(connection) { + return { + Doc: connection.models.Doc || connection.model('Doc', docSchema), + Job: mongoose.model('Job', jobSchema), + Other: connection.model('Other', otherSchema), + }; +} +module.exports = { registerModels }; diff --git a/graph/test/javascript/cases/74-orm-model-factory/src/repo.js b/graph/test/javascript/cases/74-orm-model-factory/src/repo.js new file mode 100644 index 00000000..55f3b77d --- /dev/null +++ b/graph/test/javascript/cases/74-orm-model-factory/src/repo.js @@ -0,0 +1,31 @@ +'use strict'; +const { registerModels } = require('./models'); + +class DocRepository { + constructor(models) { this.Doc = models.Doc; this.Job = models.Job; this.Other = models.Other; } + list() { return this.Doc.findLive(); } + take() { return this.Doc.claim(); } + purge() { return this.Doc.purge(); } + due() { return this.Job.due(); } + record() { return new this.Doc({}).touch(); } + save() { return this.Doc.create({}); } // the package's own member: stays unknown + other() { return this.Other.findLive(); } // control: the unmodelled package +} + +// Control: an unrelated repository with same-named methods, reached by nothing above. +class MemoryRepository { + findLive() { return []; } + claim() { return null; } +} + +// Control: a project `.model(name, x)` whose second argument is not a schema keeps +// its own return value. +const registry = { model(name, def) { return def; } }; +const plain = registry.model('x', { findLive() { return 3; } }); + +function start(connection) { + const repo = new DocRepository(registerModels(connection)); + return [repo.list(), repo.take(), repo.purge(), repo.due(), repo.record(), repo.save(), repo.other(), + plain.findLive(), new MemoryRepository()]; +} +module.exports = { start }; diff --git a/graph/test/javascript/expected/74-orm-model-factory.diag b/graph/test/javascript/expected/74-orm-model-factory.diag new file mode 100644 index 00000000..f191f565 --- /dev/null +++ b/graph/test/javascript/expected/74-orm-model-factory.diag @@ -0,0 +1,17 @@ +import_cause models.js:21:17 other-orm not_staged +import_cause models.js:7:18 mongoose not_staged +package_entry @axiomcode/code-graph . [] MAIN dist/reason.js NOT_STAGED -> - +unresolved models.js:10:19 CONSTRUCTOR_CALL Schema callee_untyped +unresolved models.js:11:51 METHOD_CALL find receiver_untyped +unresolved models.js:12:1 METHOD_CALL static receiver_untyped +unresolved models.js:13:1 METHOD_CALL static receiver_untyped +unresolved models.js:15:1 METHOD_CALL method receiver_untyped +unresolved models.js:18:19 CONSTRUCTOR_CALL Schema receiver_untyped +unresolved models.js:22:21 CONSTRUCTOR_CALL OtherSchema callee_untyped +unresolved models.js:27:35 METHOD_CALL model receiver_untyped +unresolved models.js:28:10 METHOD_CALL model receiver_untyped +unresolved models.js:29:12 METHOD_CALL model receiver_untyped +unresolved repo.js:10:21 CONSTRUCTOR_CALL Doc member_absent +unresolved repo.js:11:19 METHOD_CALL create receiver_untyped +unresolved repo.js:12:20 METHOD_CALL findLive receiver_untyped +value_callee models.js:10:19 Schema module_variable diff --git a/graph/test/javascript/expected/74-orm-model-factory.edges b/graph/test/javascript/expected/74-orm-model-factory.edges new file mode 100644 index 00000000..f6b6bf7a --- /dev/null +++ b/graph/test/javascript/expected/74-orm-model-factory.edges @@ -0,0 +1,36 @@ +models.js:10:19 CONSTRUCTOR_CALL Schema -> ambiguous_unknown - +models.js:11:51 METHOD_CALL this.find -> ambiguous_unknown - +models.js:12:1 METHOD_CALL docSchema.static -> ambiguous_unknown - +models.js:12:1 METHOD_CALL docSchema.static -> callback_registered models.js:12:27 +models.js:12:48 METHOD_CALL this.findLive -> known_edge models.js:11:30 +models.js:13:1 METHOD_CALL docSchema.static -> ambiguous_unknown - +models.js:13:1 METHOD_CALL docSchema.static -> callback_registered models.js:13:20 purge +models.js:15:1 METHOD_CALL docSchema.method -> ambiguous_unknown - +models.js:15:1 METHOD_CALL docSchema.method -> callback_registered models.js:15:27 +models.js:15:48 METHOD_CALL this.toRecord -> known_edge models.js:14:30 +models.js:18:19 CONSTRUCTOR_CALL mongoose.Schema -> ambiguous_unknown - +models.js:18:19 CONSTRUCTOR_CALL mongoose.Schema -> callback_registered models.js:18:56 due +models.js:22:21 CONSTRUCTOR_CALL OtherSchema -> ambiguous_unknown - +models.js:27:35 METHOD_CALL connection.model -> ambiguous_unknown - +models.js:28:10 METHOD_CALL mongoose.model -> ambiguous_unknown - +models.js:29:12 METHOD_CALL connection.model -> ambiguous_unknown - +repo.js:10:21 CONSTRUCTOR_CALL this.Doc -> ambiguous_unknown - +repo.js:10:21 METHOD_CALL new this.Doc({}).touch -> known_edge models.js:15:27 +repo.js:11:19 METHOD_CALL this.Doc.create -> ambiguous_unknown - +repo.js:12:20 METHOD_CALL this.Other.findLive -> ambiguous_unknown - +repo.js:24:15 METHOD_CALL registry.model -> known_edge repo.js:23:20 model +repo.js:27:16 CONSTRUCTOR_CALL DocRepository -> known_edge repo.js:5:3 +repo.js:27:34 FUNCTION_CALL registerModels -> known_edge models.js:25:1 registerModels +repo.js:28:11 METHOD_CALL repo.list -> known_edge repo.js:6:3 list +repo.js:28:24 METHOD_CALL repo.take -> known_edge repo.js:7:3 take +repo.js:28:37 METHOD_CALL repo.purge -> known_edge repo.js:8:3 purge +repo.js:28:51 METHOD_CALL repo.due -> known_edge repo.js:9:3 due +repo.js:28:63 METHOD_CALL repo.record -> known_edge repo.js:10:3 record +repo.js:28:78 METHOD_CALL repo.save -> known_edge repo.js:11:3 save +repo.js:28:91 METHOD_CALL repo.other -> known_edge repo.js:12:3 other +repo.js:29:23 CONSTRUCTOR_CALL MemoryRepository -> implicit_constructor - +repo.js:29:5 METHOD_CALL plain.findLive -> known_edge repo.js:24:37 findLive +repo.js:6:19 METHOD_CALL this.Doc.findLive -> known_edge models.js:11:30 +repo.js:7:19 METHOD_CALL this.Doc.claim -> known_edge models.js:12:27 +repo.js:8:20 METHOD_CALL this.Doc.purge -> known_edge models.js:13:20 purge +repo.js:9:18 METHOD_CALL this.Job.due -> known_edge models.js:18:56 due diff --git a/graph/test/javascript/expected/74-orm-model-factory.oracle b/graph/test/javascript/expected/74-orm-model-factory.oracle new file mode 100644 index 00000000..914865e5 --- /dev/null +++ b/graph/test/javascript/expected/74-orm-model-factory.oracle @@ -0,0 +1,12 @@ +repo.js:24:15 METHOD_CALL model EXACT repo.js:23:20 +repo.js:27:16 CONSTRUCTOR_CALL DocRepository EXACT repo.js:5:3 +repo.js:27:34 FUNCTION_CALL registerModels EXACT models.js:25:1 +repo.js:28:11 METHOD_CALL list EXACT repo.js:6:3 +repo.js:28:24 METHOD_CALL take EXACT repo.js:7:3 +repo.js:28:37 METHOD_CALL purge EXACT repo.js:8:3 +repo.js:28:51 METHOD_CALL due EXACT repo.js:9:3 +repo.js:28:63 METHOD_CALL record EXACT repo.js:10:3 +repo.js:28:78 METHOD_CALL save EXACT repo.js:11:3 +repo.js:28:91 METHOD_CALL other EXACT repo.js:12:3 +repo.js:29:23 CONSTRUCTOR_CALL MemoryRepository SYNTHESIZED_OK +# defects: 0