Skip to content

Indirect calls: function pointers and macro bodies - #50

Open
rikvanriel wants to merge 54 commits into
facebookexperimental:mainfrom
rikvanriel:scratch/riel/funcpointers-receiver-typing
Open

Indirect calls: function pointers and macro bodies#50
rikvanriel wants to merge 54 commits into
facebookexperimental:mainfrom
rikvanriel:scratch/riel/funcpointers-receiver-typing

Conversation

@rikvanriel

Copy link
Copy Markdown

Indirect calls: function pointers and macro bodies

semcode could not answer the question in issue #9:

semcode> callers tcp_v4_rcv
Info: No functions call 'tcp_v4_rcv'

Nothing calls it by name. The kernel reaches it through net_protocol::handler,
installed once in inet_init and dispatched through in ip_protocol_deliver_rcu.
Every fact needed to say so was in the tree, and none of it was indexed.

The same gap swallowed macros. A #define was stored as text: the calls in its
body were not edges, its parameters were not recorded, and a dispatch or a
registration written inside one existed nowhere.

This series indexes both. A call that goes through a member is recorded as a
dispatch site; a function installed in a member is recorded as a
registration; a query joins them. Macro bodies are parsed with the C grammar
and contribute the same three kinds of fact as any other code.

Three commands are new — implementors, registrations, and indirect results
in callers — and 19 existing MCP tools gain the same answers.


Function pointers

1. Who can reach a function nothing calls by name

semcode> callers tcp_v4_rcv

before:

Info: No functions call 'tcp_v4_rcv'

after:

=== Indirect Callers ===
1 call sites can reach it through a function pointer:
  1. ip_protocol_deliver_rcu at net/ipv4/ip_input.c:207 [macro_declared]
     names it at the call site

Note: 25 further call sites go through a member of the same name, but
nothing says their receiver has the type the function was installed in.

The answer comes from INDIRECT_CALL_2(ipprot->handler, tcp_v4_rcv, udp_rcv, skb),
where the macro names its own likely targets. The note is the honest remainder:
25 other calls through a member called handler whose receiver type is unknown.
They are counted, not listed, because they are evidence of a different quality.

2. The VFS shape, resolved through the types table

semcode> callers seq_read

before:

=== Direct Callers ===
1 functions directly call 'seq_read':
  1. pstore_file_read

after:

=== Direct Callers ===
1 functions directly call 'seq_read':
  1. pstore_file_read

=== Indirect Callers ===
3 call sites can reach it through a function pointer:
  1. do_loop_readv_writev at fs/read_write.c:848 [member_arrow]
     installed in file_operations::read at arch/arm64/kvm/ptdump.c:204
     and 343 other places (receiver type matches)
  2. loop_rw_iter at io_uring/rw.c:733 [member_arrow]
     installed in file_operations::read at arch/arm64/kvm/ptdump.c:204
     and 343 other places (receiver type matches)
  3. vfs_read at fs/read_write.c:572 [member_arrow]
     installed in file_operations::read at arch/arm64/kvm/ptdump.c:204
     and 343 other places (receiver type matches)

vfs_read writes file->f_op->read(...). Typing that receiver takes two steps:
the calling file proves file is a struct file, and the types table says
f_op is a struct file_operations *. The first happens while parsing, the
second at query time, because the header declaring struct file is a different
file.

A site is one answer however many places install the target. seq_read sits in
that member in 344 of them, and the answer names one and counts the rest.

3. Two questions that could not be asked at all

semcode> implementors file_operations.read

before:

Error: Unknown command: 'implementors'. Type 'help' for available commands.

after:

=== Implementors ===
1443 installed:
  1. seq_read at .../octeontx2/af/cn20k/debugfs.c:160 in __OCTEONTX2_DEBUGFS_ATTRIBUTE_FOPS [designated_init]
  2. dev_read at drivers/net/ethernet/mellanox/mlx5/core/lib/sd.c:772 [designated_init]
  3. iwl_dbgfs_monitor_data_read at .../iwlwifi/pcie/gen1_2/trans.c:3283 [designated_init]
  ...

and the reverse:

semcode> registrations tcp_v4_rcv

before:

Error: Unknown command: 'registrations'. Type 'help' for available commands.

after:

=== Registrations ===
1 places install it:
  1. net_protocol::handler at net/ipv4/af_inet.c:1934 in inet_init [designated_init]

"What can be called here?" and "where is this hooked up?" are the two questions
review actually asks about a callback, and both are one lookup.


Macros

4. A macro body is code

semcode> func wait_event

before:

Declaration:  wait_event(void)

#define wait_event(wq_head, condition)          \
do {                                            \
        might_sleep();                          \
        if (condition)                          \
                break;                          \
        __wait_event(wq_head, condition);       \
} while (0)

Called By: 416

after:

Declaration:  wait_event( wq_head,  condition)

[same definition]

Calls: 2
  → __wait_event
  → might_sleep

Called By: 416

Before, the body was text: no parameters, no callees. 416 callers of
wait_event led nowhere, because the chain stopped at the macro. Now the two
functions it calls are edges, and the parameters it takes are recorded — for a
Linux tree, 133,341 macros gained the 214,769 parameters they declare.

The body is parsed with the C grammar rather than scanned, which is what keeps
while, if and sizeof from being read as callees: 13,137 such edges do not
exist, out of 18,806 wrong ones removed.

5. A macro that dispatches

semcode> func nvkm_memory_target

before:

Declaration:  nvkm_memory_target(void)
#define nvkm_memory_target(p) (p)->func->target(p)

Called By: 22

after:

Declaration:  nvkm_memory_target( p)
#define nvkm_memory_target(p) (p)->func->target(p)

Called By: 22

The definition is unchanged, and so is the absence of a Calls: line — on
purpose. target is a member, not a function, and the accessor is recorded as
a dispatch site through target instead of as a call to something that does
not exist. Whole subsystems put their indirection in a macro like this: 800
dispatch sites in a Linux tree are written inside a macro body.

6. A macro that installs

semcode> func DEFINE_PMC_CORE_ATTR_WRITE

#define DEFINE_PMC_CORE_ATTR_WRITE(__name)                      \
static int __name ## _open(struct inode *inode, struct file *file) \
{ ... }                                                         \
static const struct file_operations __name ## _fops = {         \
        .owner          = THIS_MODULE,                          \
        .open           = __name ## _open,                      \
        .read           = seq_read,                             \
        .write          = __name ## _write,                     \
        .release        = single_release,                       \
}

before: the five installations in that table were not recorded anywhere, so
seq_read did not appear to be installed by this driver at all.

after:

semcode> registrations seq_read
  5. file_operations::read at drivers/platform/x86/intel/pmc/core.h:648
     in DEFINE_PMC_CORE_ATTR_WRITE [designated_init]
114. file_operations::read at drivers/gpu/drm/i915/gt/intel_gt_debugfs.h:17
     in __GT_DEBUGFS_ATTRIBUTE_FOPS [designated_init]
174. file_operations::read at drivers/nvme/target/debugfs.c:24
     in NVMET_DEBUGFS_ATTR [designated_init]

Macros that declare an ops table are how a large part of the kernel installs
callbacks; 1,955 registrations in a Linux tree are written inside one. A body
that states no type of its own — a bare { .read = seq_read } — registers
nothing, since the type would have to be guessed from context.


At kernel scale

Whole tree, 1,020,780 functions:

dispatch sites 72,605 — 62,130 through ->, 7,952 through ., 1,378 through a parameter, 530 a local, 437 a dereferenced pointer, 178 named by an indirect-call macro
registrations 575,240 — 330,226 designated initializers, 245,014 assignments
sites with a receiver type 19,269 typed while parsing, 22,042 more resolved through the types table
call edges removed 19,389 that named a keyword, a member, or an assembler directive rather than a function
macro parameters gained 214,769 across 133,341 macros

What this does not do

  • A receiver the file does not declare stays untyped, and a chain longer than
    one step (a->b->c->m()) is not resolved. Both are counted in the note, not
    guessed at.
  • callchain does not yet expand through an indirect call; resolution returns
    the candidates, and using them there is the next patch.
  • There is no policy knob yet for how much fanout to show. Answers are grouped
    by site and capped by the caller.
  • Registrations record the target as written, so a member set to a constant is
    stored too. The join against known functions filters those inertly.

Series layout

Four branches, applied in order:

funcpointers                      5   shared prerequisites: candidate-returning
                                      lookups, declarator parsing, loud failure on
                                      an unparsable edge list
funcpointers-indirect-call-sites +5   dispatch sites: member calls, pointer
                                      variables, INDIRECT_CALL_* targets
funcpointers-registrations       +7   registrations, the reverse query, the two new
                                      commands, MCP, end-to-end tests
funcpointers-macro-bodies        +5   macro bodies parsed as C, with the sites and
                                      registrations inside them
funcpointers-receiver-typing     +5   receiver typing, chained receivers resolved
                                      through the types table, one answer per site

27 patches in total, each branch stacked on the one above it.

Every patch builds and passes cargo test, cargo clippy -D warnings and
cargo fmt --check on its own. Each was also run against two real trees, chrony
and Linux, comparing every table's row counts against the previous patch and
re-indexing to confirm the result is idempotent. Three defects in this series
were invisible to the unit tests and caught only by that comparison.

Closes #9
Closes #35

get_by_names is 78 lines and does three separate things: build a name
filter and fetch matching metadata rows, bulk fetch the bodies those rows
reference, and assemble FunctionInfo values from the two.

Widening its return type to carry every candidate for a name touches only
the third step, so move the other two out first, into name_in_filter(),
fetch_metadata_by_names() and fetch_bodies_for_hashes(). Body assembly
becomes metadata_into_function().

No behavior change: the same rows are fetched with the same filter and
assembled the same way, and the function drops to well under the length
where its parts stop being separable.

Assisted-by: claw:claude-opus-5
Signed-off-by: Rik van Riel <riel@surriel.com>
The bulk lookup keys its result by function name, so N definitions sharing
a name collapse to one arbitrary survivor. Two files each defining a static
dup_caller() are indexed as two rows, and the lookup hands back one of them,
chosen by whichever row the scan reached last.

Map each name to every definition carrying it. Consumers keep selecting the
first candidate, so output is unchanged by this patch: path analysis takes
the first entry, and the caller helpers take one entry per name exactly as
the collapsing map gave them.

This is not the whole of the same-name problem. The callers command loses
duplicates one layer lower, where get_function_callers_with_manifest returns
caller names as strings and get_function_callers_git_aware deduplicates
them, so identity is already gone before any lookup runs. Restoring it there
is a separate change.

Assisted-by: claw:claude-opus-5
Signed-off-by: Rik van Riel <riel@surriel.com>
Struct members were extracted by splitting declaration text on commas and
whitespace, so any member whose declarator contains a comma came out
wrong. A function-pointer member ended up named after its own declaration:

    struct file_operations {
            int (*read)(struct file *f, char *buf);

    semcode> type file_operations
    Fields:
      - int (*read)(struct file * f

The parse tree already contains what the text parser was reconstructing:
the member name is a field_identifier at the bottom of the declarator, and
everything around it is the type. Walk the declarator instead, taking the
identifier as the name and the declarator with that identifier removed as
the abstract type, so the same query becomes

    semcode> type file_operations
    Fields:
      - int (*)(struct file *f, char *buf) read

Multiple declarators per declaration, bitfields, arrays, pointers to
functions, arrays of function pointers, and qualifiers that sit beside the
type node rather than inside it are all handled by the same walk. Inline
anonymous aggregates render as `struct {...}` instead of an entire nested
body.

The text-based field parsers this replaces are removed. The line-based
fallback for bodies the grammar could not parse at all stays.

An anonymous struct or union member has no declarator of its own, and C makes
its members members of the enclosing struct, so they are reported that way. A
named inline aggregate reports both the member and what it holds, qualified —
`named` and `named.v1` — since an inline type has no name to look its members
up by. Error recovery can leave a zero-width identifier behind; those are
dropped rather than stored as a member with no name.

Assisted-by: claw:claude-opus-5
Signed-off-by: Rik van Riel <riel@surriel.com>
Parameter extraction walked a parameter_declaration node kind by kind,
accumulating type fragments and taking the first bare identifier as the
name. A function-pointer parameter hides its name inside a declarator, so
the walk found no identifier and produced a nameless parameter with the
type collapsed to an empty pointer:

    int deref(int (*fp)(struct file *, char *), struct file *f)

    semcode> func deref
    Declaration: int deref(int (*) , struct file * f)

Use the same declarator walk the struct fields now use: the innermost
identifier is the name, and the declarator with that identifier removed is
the abstract type. The same query then reports

    semcode> func deref
    Declaration: int deref(int (*)(struct file *, char *) fp, struct file * f)

An abstract parameter declares no name, so its declarator is the type
unchanged. The kind-matching pointer, array and function declarator helpers
this replaces are removed; the fallback for parameter lists the grammar
gives no parameter_declaration for stays.

Assisted-by: claw:claude-opus-5
Signed-off-by: Rik van Riel <riel@surriel.com>
The calls column holds a JSON array of callee names, and every reader
parsed it with serde_json::from_str::<Vec<String>>(..) followed by .ok() or
an `if let Ok(..)`. A value that does not parse therefore reads as a
function that calls nothing, which is indistinguishable from a function
that really calls nothing: no error, no warning, an empty call graph.

That matters because the column's contents are about to grow new
producers. A writer that emits something a reader does not understand
would silently empty the call graph for every function it touched, and the
first symptom would be a callchain that looks plausible and is wrong.

Parse through one helper that returns an error naming the offending value,
and propagate it at the eight sites that read the column. Well-formed
values behave exactly as before; the tests cover a truncated value, an
object where a list belongs, and a list of objects, which is the shape a
future encoding would most likely take.

The types column has the same pattern and is left alone here.

Assisted-by: claw:claude-opus-5
Signed-off-by: Rik van Riel <riel@surriel.com>
extract_all_calls_optimized walks every call-query capture and, inline,
decides whether the captured text is a call worth recording and what to
record for it. Recording indirect calls means that decision grows: a
member call, a call through a pointer variable and a call through a macro
each need different treatment.

Move the per-capture decision into call_site_from_capture() so the walk
and the classification stop being the same function. No behavior change:
the same captures produce the same names and byte ranges.

Assisted-by: claw:claude-opus-5
Signed-off-by: Rik van Riel <riel@surriel.com>
A call through a struct member records the member name as if it were the
function called:

    int read(struct file *f);              /* somewhere else entirely */
    int go(struct ops *o) { return o->read(f); }

    semcode> func go
      Calls: 1
        -> read

Nothing distinguishes that from a direct call to read(), so callchain
resolves it by name and prints a confident file:line for a function the
code cannot reach through this call. Across the kernel, 43,308 member call
sites in .c files record a bare member name this way, and 16,015 of them
name something that also exists as a real function.

A member call names a member, not a function, and its targets are whatever
the program installs in that member. Record the site instead: which member,
the receiver as written, whether it was reached through `->` or `.`, and
where it is. The candidates it can reach are resolved later by joining
against the functions installed in that member, which keeps indexing
per-file and incremental.

Sites go in their own table rather than the calls column, since a call
edge names one target and a dispatch site names none. That also leaves the
stored format of calls alone: a member name simply stops being written to
it, so an older binary reading a newer database sees fewer and more
accurate edges rather than a shape it cannot parse.

A site is identified by file, content hash and byte offset, so reindexing
unchanged content stores the same row again rather than a duplicate. The
enclosing function is recorded when there is one; Python module level and
class bodies, and C++ and Rust static initializers, dispatch outside any
function, and those sites carry file and line only.

Resolution, and the other ways a call can go through a value, come next.

Assisted-by: claw:claude-opus-5
Signed-off-by: Rik van Riel <riel@surriel.com>
Two shapes of pointer call are mishandled. `(*fp)(args)` matches no call
pattern, so the call site is absent from the index entirely: 6,864 sites
in kernel .c files. And `fp(args)`, where fp is a local or a parameter,
records a call to a function named after the variable, which resolves to
whatever unrelated function happens to share the name:

    int fp(struct file *f);                    /* elsewhere */
    int go(struct file *f) {
            int (*fp)(struct file *) = my_read;
            return fp(f);                      /* recorded as calling fp() */
    }

Record both as dispatch sites. A dereferenced call is one syntactically.
For a plain call, the function's own declarations decide: a name declared
in it as a function pointer is a value, not a function, so the call
dispatches and the name stops appearing in the call list.

A declaration that says what the pointer was initialised with names its
target, so `int (*fp)(...) = my_read;` gives the site a target of my_read
and it needs no further resolution. A pointer parameter names none, and
says so.

Assisted-by: claw:claude-opus-5
Signed-off-by: Rik van Riel <riel@surriel.com>
The kernel wraps hot indirect calls in macros that name the targets they
expect, so the branch predictor can guess:

    ret = INDIRECT_CALL_2(ipprot->handler, tcp_v4_rcv, udp_rcv, skb);

This is the one place the source states outright what an indirect call can
reach. Record a site per named candidate, all describing the same dispatch
through the same member, so tcp_v4_rcv is reachable from
ip_protocol_deliver_rcu without resolving anything.

How many candidates a macro names comes from the macro, not from the shape
of its arguments: INDIRECT_CALL_2 names two and INDIRECT_CALL_INET_1 one,
while the call's own arguments that follow are identifiers just as often as
the candidates are (include/linux/indirect_call_wrapper.h).

There are 121 such sites in the tree, so this is narrow. It is also a set
of known-correct answers to check a resolver against: for each of them the
candidate set produced by resolution must contain what the macro names.

Assisted-by: claw:claude-opus-5
Signed-off-by: Rik van Riel <riel@surriel.com>
Macro bodies are not parsed as expressions, so the calls in them are found
by scanning text for an identifier followed by an opening parenthesis. The
scan splits the body on whitespace and accepts a token only when it ends
with '(', or when the next token is '(' on its own:

    #define TWICE(x)        helper( x )     -> records helper
    #define TIGHT(x)        helper(x)       -> records nothing

which is the spelling almost no C uses. Over include, net, fs, kernel and
mm, 6,084 function-like macros have a call-like token in their body: 5,617
are written ident( and are missed, 467 are written ident ( and are found.

So a wrapper macro contributes no edges, and a chain through the macro
stops there:

    #define xa_lock_irq(xa) spin_lock_irq(&(xa)->xa_lock)

    semcode> callers spin_lock_irq
    Info: No functions call 'spin_lock_irq'

    semcode> func xa_lock_irq
    Called By: 1
      <- user

Look for the identifier immediately before each '(' instead of relying on
how the body is spaced, and skip the directive, the macro name and its
parameter list first, since `NAME(` looks exactly like a call.

With the fix, the wrapper reports what it calls and the chain joins up:

    semcode> callers spin_lock_irq
    === Direct Callers ===
    1 functions directly call 'spin_lock_irq':
      1. xa_lock_irq

Parsing the body properly, rather than scanning it, is the better fix and
needs the body re-parsed with the grammar; this is the part worth having
now, and its effect on edge counts is attributable on its own.

Assisted-by: claw:claude-opus-5
Signed-off-by: Rik van Riel <riel@surriel.com>
Analysing a file yields four kinds of fact and returns them as a tuple, so
every call site destructures four unnamed elements and every new kind of
fact widens the tuple and edits those sites again. Registrations are the
next kind, and the C++ and Python work adds more.

Name them: FileAnalysis carries functions, types, macros and dispatch
sites, and callers take the fields they want. No behavior change.

Assisted-by: claw:claude-opus-5
Signed-off-by: Rik van Riel <riel@surriel.com>
A dispatch site says a call goes through `file_operations::read`; nothing
says what is installed there. The other half is written in initializers:

    static const struct file_operations fops = {
            .read = my_read,
    };

Record those as registrations, keyed by the type and member they fill in,
so resolution can join a site against them. The container type comes from
whatever states it — the declaration, or the compound literal's cast, which
is how the kernel writes it inside a function:

    net_hotdata.tcp_protocol = (struct net_protocol) {
            .handler = tcp_v4_rcv,

A nested initializer states no type of its own; the member's type is
declared with the struct, usually in another file. Those are skipped rather
than filed under a guess, since a registration under the wrong type joins
with the wrong dispatch sites.

The target is recorded as written. Whether an identifier names a function
is not knowable while parsing one file, and does not need to be: resolution
joins the target against the functions table, so `.flags = DEFAULT_MASK`
never matches anything. A kernel index holds 328,387 registrations on that
basis.

Also adds schema_meta, holding the schema version and the point from which
each of these tables started being populated. A column being present says
the schema was migrated; it does not say which rows predate the feature,
and indexing is incremental per file hash, so that mark is what makes a
backfill decidable later.

Assisted-by: claw:claude-opus-5
Signed-off-by: Rik van Riel <riel@surriel.com>
An ops table is not always written as an initializer:

    static int setup(struct ops *o) { o->run = impl; }

installs impl in ops::run exactly as `.run = impl` does, and a dispatch
site through that member should find it.

Record those, with the same rule as initializers about types: the receiver
has to be a variable this file declares, since the struct is usually
declared in a header the file does not contain and a registration filed
under a guessed type joins with the wrong dispatch sites. An assignment
through anything else — a macro, a container_of, a call result — is
skipped.

On a kernel index this adds 244,898 rows, of which 21% name an indexed
function, against 56% for designated initializers. Most member assignments
set data rather than install functions, and as with initializers the target
is recorded as written: resolution joins it against the functions table, so
`sk->sk_state = TCP_CLOSE` never matches. If the noise proves to cost
anything, the cheap filter is to skip an all-capitals target, which is a
constant by convention in this codebase.

Assisted-by: claw:claude-opus-5
Signed-off-by: Rik van Riel <riel@surriel.com>
A function reached only through a function pointer has no callers:

    semcode> callers tcp_v4_rcv
    Info: No functions call 'tcp_v4_rcv'

even though net/ipv4/af_inet.c installs it in net_protocol::handler and
ip_protocol_deliver_rcu dispatches through that member.

Join the two: for each place a function is installed, find the sites that
dispatch through that member, and report them with the evidence, since a
reader has to be able to check a claim that rests on two facts from
different files. A site that names the function outright — an
INDIRECT_CALL_n candidate, a local pointer's initializer — needs no join
and says so.

    semcode> callers tcp_v4_rcv
    === Indirect Callers ===
    1 call sites can reach it through a function pointer:
      1. ip_protocol_deliver_rcu at net/ipv4/ip_input.c:207 [macro_declared]
         names it at the call site

    Note: 138 further call sites go through a member of the same name, but
    nothing says their receiver has the type the function was installed in.

That note is the limit of what this can answer today, and it is deliberate.
Matching a site to a registration by member name alone reaches every call
through any member of that name: for `handler` that is most of the kernel,
and listing 139 sites of which one is right is not an answer. So a site
counts only when it states a receiver type that matches the type the
function was installed in, or when it names the function itself.

Recording the receiver type at a call site is a separate change, and until
it lands most sites state no type and stay in that note. When it does, they
become answers without anything here changing.

Rows are filtered to the revision being queried, as function lookups are,
so a registration deleted in a later commit stops answering.

Assisted-by: claw:claude-opus-5
Signed-off-by: Rik van Riel <riel@surriel.com>
The registration table answers two questions directly, without any
resolution: what is installed in a member, and where a function is
installed. Both are useful on their own — the first is "what can this
dispatch reach", the second is "how is this function ever called".

    semcode> registrations tcp_v4_rcv
    === Registrations ===
    1 places install it:
      1. net_protocol::handler at net/ipv4/af_inet.c:1934 in inet_init

    semcode> implementors file_operations.read
    === Implementors ===
    1424 installed:
      1. iwl_dbgfs_monitor_data_read at drivers/.../trans.c:3283
      ...

`implementors` takes `type.member`, or the two as separate words, and
accepts a leading `struct` since that is how the type is usually written.
Both filter to the revision being queried, as the other lookups do.

Assisted-by: claw:claude-opus-5
Signed-off-by: Rik van Riel <riel@surriel.com>
An agent asking find_callers about a function reached only through a
function pointer was told nothing calls it. It now gets the same answer the
query tool gives, in the same shape: a section of indirect callers with the
evidence for each, and a count of the sites that match by member name alone.

    find_callers {"name": "tcp_v4_rcv"}
    Finding all functions that call: tcp_v4_rcv
    Info: No functions call function 'tcp_v4_rcv'

    === Indirect Callers ===
    1 call sites can reach it through a function pointer:
      1. ip_protocol_deliver_rcu at net/ipv4/ip_input.c:207 [macro_declared]
         names it at the call site

Two tools are added for the questions the registration table answers
directly: find_implementors, for what is installed in a struct member, and
find_registrations, for where a function is installed.

Existing tools keep their names and input schemas, so a consumer that
passes what it always passed gets what it always got, plus the new section
in the text. That matters here because these consumers read a text blob:
callers are not returned as structured data, and a section header is what
distinguishes them.

Assisted-by: claw:claude-opus-5
Signed-off-by: Rik van Riel <riel@surriel.com>
The pieces of this are unit tested, but what has to work is the whole path:
extraction, storage, revision filtering and the join. Only indexing real
files exercises that, and the shape worth exercising is the one from the
report — a handler installed by a compound literal inside a function, and a
call site that reaches it through an indirect-call macro.

tests/indirect_calls.rs indexes such a tree into a temporary repository and
asserts what a user would ask: tcp_v4_rcv has no direct callers but is
reached from ip_protocol_deliver_rcu, the registration is found at the line
that writes it with inet_init as its enclosing function, a plain member call
matches by member name and stays in the weaker set, and a function installed
nowhere reports nothing rather than everything.

It also asserts that `ipprot->handler(skb)` does not record a callee named
handler. That one fails against the series before dispatch sites existed —

    member name recorded as a callee: ["handler"]

which is the regression the rest guards against.

tests/test_callchain.sh could not run at all: it passed
--no-strict-compile-commands, an option that no longer exists, and with set
-e it aborted on its first command. Indexing also walks the whole git tree
its source lives in, so pointing it at the tests directory indexed all of
semcode. It now copies its fixtures into a temporary repository, finds the
binaries whether or not build.sh has made the bin symlinks, and leaves
nothing behind.

Assisted-by: claw:claude-opus-5
Signed-off-by: Rik van Riel <riel@surriel.com>
Calls inside a macro body were found by scanning text for an identifier
before a parenthesis. That cannot tell a call from anything else spelled the
same way, so the edges it produced included a great many things that are not
calls at all.

Parse the body instead. A `#define` body is not a translation unit — it can
be an expression, a statement, a declaration or an initializer — so it is
tried in each of those contexts and the cleanest parse wins. The call query
then reports what is actually a call:

    #define NESTED(x)  helper(other(x) + 1)       -> helper, other
    #define CAST(x)    ((unsigned long)helper(x)) -> helper
    #define GUARD(x)   if (x) helper(x)           -> helper, not if
    #define GROUPED(x) ((x) + 1)                  -> nothing

Many bodies are fragments that are not valid C alone, `"prefix: " fmt` being
the common shape, and parse into something with no call in them. The scan
still answers for those, so a call that was found before is not lost.

Measured by indexing a Linux tree, 1,020,780 functions, before and after:
call edges fall from 3,185,082 to 3,166,276, a drop of 18,806. Of those,
13,137 are keywords that take a parenthesised operand:

    while   4,942      switch    170
    if      3,966      __typeof__ 117
    sizeof  1,979      return      93
    for     1,351      case        15
    typeof    504

The rest are of the same kind: offsetof 248, __stringify 91, volatile 75,
__volatile__ 72, __attribute__ 54, _Generic 42, __asm__ 26, __alignof__ 23,
asm 22, and halves of token-paste names such as _show 183 and _store 65.
No function name appears among the losses.

Reading the body also requires the macro's own query match to be complete.
The walk used captures(), which yields a match again for each capture as it
is found, so an early yield had no body. It now uses matches(), which yields
once with everything present — and as a side effect macro parameter lists
stop being dropped. In the same tree, of 136,480 macro rows, 133,341 now
carry the 214,769 parameters they declare, where previously none carried
any.

Assisted-by: claw:claude-opus-5
Signed-off-by: Rik van Riel <riel@surriel.com>
Whole subsystems put their indirection in a macro. include/linux/efi.h
writes the EFI call as `((p)->f(args))`, fwnode_call_int_op dispatches
through `(fwnode)->ops->`, and a caller of those macros records an edge to
the macro and nothing else: the dispatch itself was invisible.

Macro bodies are parsed now, so run the same call walk over the parsed body
and keep the sites it finds. Positions come back relative to the body and
are mapped into the file, so a site is reported where it is written.

    #define CALL_RUN(o) ((o)->run())

records a member dispatch through `run`, attributed to CALL_RUN. A function
that expands the macro gets no site of its own — the expansion is not
visible in the source it is written in — so the dispatch is reachable
through the macro, which is where it lives.

Indexing a Linux tree adds 800 sites: 564 through `->`, 207 through `.`, 23
named by an indirect-call macro, and 6 through a dereferenced pointer.

Assisted-by: claw:claude-opus-5
Signed-off-by: Rik van Riel <riel@surriel.com>
A macro can install a function as well as call one:

    #define DEFINE_OPS(name, fn) struct ops name = { .run = fn }

ACPI builds its method tables this way, error injection declares its
entries this way, and none of it was recorded, so a dispatch through that
member could not reach what the macro installs.

Macro bodies are parsed now, so run the initializer and assignment walks
over the parsed body as well, mapping positions back into the file. The
registration is attributed to the macro, which is where it is written.

A body that states no type of its own registers nothing. `{ .run = impl }`
only parses as an initializer because the body is wrapped in a declaration
to parse it at all, and that declaration's type belongs to the wrapper, not
to the macro. Recording it would file the registration under a type that
appears nowhere in the source, so those are dropped, as they are for a
nested initializer elsewhere.

Indexing a Linux tree adds 1,955 registrations: 1,839 from initializers and
116 from assignments. Many name a macro parameter rather than a function,
which resolution filters out the same way it filters a constant.

Assisted-by: claw:claude-opus-5
Signed-off-by: Rik van Riel <riel@surriel.com>
Some macro bodies are not C. Under __ASSEMBLER__, arch/arm64/include/asm/
asm-extable.h writes

    #define __ASM_EXTABLE_RAW(insn, fixup, type, data)	\
	    .pushsection	__ex_table, "a";	\
	    .long		((insn) - .);		\
	    .short		(type);

and read as C, `.long ((insn) - .)` is a call through a member named
`long`. Parsing macro bodies made those readable, so the sites appeared:
the x86 and arm64 exception tables, the loongarch ones, and the powerpc
instruction macros, which reach the same directives through str() and
stringify_in_c().

A struct has no member named `long`, because C will not allow one. Drop a
site or a registration whose member is a keyword, wherever it is found: the
reading is wrong in a function body just as it is in a macro.

This removes 29 sites from a Linux tree, 27 through `.` and 2 through `->`,
and no registrations. Rejecting instead every fact from a body that parses
with errors would have covered the same cases, but it costs 134 sites and
819 registrations, since a body that recovers from an error can still state
plainly which function it installs.

Assisted-by: claw:claude-opus-5
Signed-off-by: Rik van Riel <riel@surriel.com>
A macro body whose only call goes through a member recorded a call to the
member:

    semcode> func nvkm_memory_target
    #define nvkm_memory_target(p) (p)->func->target(p)

    Calls: 1
      → target

There is no function called `target`. The parse classifies that call
correctly and produces no callee name for it, but the scan that rescues
bodies the grammar cannot read runs whenever no callee was named, sees
`target(` and takes it. The dispatch itself is recorded as a site, so the
call edge is both wrong and redundant.

Run the scan only when the parse found no call at all, rather than when it
named no function. A body with a member call has a call the scan must not
re-read; a body that is a fragment still has none, and is still scanned.

This drops 583 edges from a Linux tree, all of them members of a struct
rather than functions.

Assisted-by: claw:claude-opus-5
Signed-off-by: Rik van Riel <riel@surriel.com>
A dispatch site said only which member it called. Asking who can reach
tcp_v4_rcv, installed in net_protocol::handler, answered with every call
through a member named `handler` anywhere in the tree, because nothing said
which struct those receivers were.

Where the file declares the receiver, record what it was declared as:

    static int probe(struct file_operations *ops) { ops->read(...); }

`ops` is declared in the parameter list, so the site dispatches through
file_operations::read, and a registration in a different struct with a
member of the same name no longer matches it.

Only declarations in scope are used. A receiver declared in another
function is not in scope, a receiver the file never declares stays untyped,
and a name declared as two different types in one function stays untyped
too, since the scope does not say which one a use means. A receiver that is
itself a member access, `inode->i_fop->read()`, needs the type of the field
rather than of a declaration; that lookup belongs to the types table at
query time and is not done here.

Indexing a Linux tree types 19,269 of 72,605 sites: 18,690 of 62,130
through `->`, 484 of 7,952 through `.`. Of the typed sites, 15,998 join a
registration on (type, member), so the type is usually the one something is
installed in rather than an unrelated struct.

    semcode> callers tcp_v4_rcv

still reports the one call site that names it. What changes is the note
under it:

    -Note: 139 further call sites go through a member of the same name, but
    -nothing says their receiver has the type the function was installed in.
    +Note: 35 further call sites go through a member of the same name, but
    +nothing says their receiver has the type the function was installed in.

The 104 that went away are sites now known to dispatch through a member of
that name in some other struct. `callers seq_read` moves the same way, from
250,260 to 134,332, which is still not a number anyone can use; a later
patch says why.

The end-to-end fixture gains a second answer from this: its plain member
call is on a declared receiver, so it is now type-matched rather than a
member-name match. The weaker case it used to cover moves to a call on a
receiver the file does not declare, which is what that evidence level is
for.

Assisted-by: claw:claude-opus-5
Signed-off-by: Rik van Riel <riel@surriel.com>
Typing a receiver from its declaration reaches `ops->read()`, where the
file declares `ops`. It does not reach the shape the kernel dispatches
through most:

    file->f_op->read(...)

Here the receiver is `file->f_op`, and its type is whatever `f_op` is
declared as, in whichever file declares struct file. That is a lookup in
the types table, which is complete only once the whole tree is indexed, so
it cannot be answered while parsing one file.

Record what the file does prove: the type of the base and the name of the
field, in two new columns. Resolution turns the pair into the receiver's
type later. They are kept apart from `receiver_type` rather than folded
into it, because a join on the base type silently matches registrations in
the wrong struct: `file` is not `file_operations`.

One step only. `a->b->c` needs the type of `b` before the type of `c`, and
a stored pair answers one hop. A base the file does not declare records
nothing, as before.

Of 72,605 sites in a Linux tree, 22,042 now carry the pair, next to the
19,269 typed outright. The fields they read are ops tables: `ops` 6,851,
`funcs` 1,700, `func` 578, then driver-specific ones. Nothing resolves
through them yet; this patch only stores the pair, and adds two columns, so
an index built by an earlier revision has to be rebuilt to gain them.

Assisted-by: claw:claude-opus-5
Signed-off-by: Rik van Riel <riel@surriel.com>
`find_by_name` asks for one row and takes it. A tree holds several types
with the same name: the kernel's `struct file` and the ones in tools and
tests, four of them in a Linux tree. Whichever row comes back first is the
answer.

That is enough for showing a definition, where any of them is a reasonable
thing to print, and wrong for deciding what a field of that struct is
declared as, where the four disagree and the caller cannot tell. Add a
lookup that returns all of them, so a caller that needs a true answer can
see whether the definitions agree.

No caller yet; the next patch resolves receiver types with it.

Assisted-by: claw:claude-opus-5
Signed-off-by: Rik van Riel <riel@surriel.com>
Resolution emitted one answer per (site, registration) pair, so a target
installed in a widely used member came back as the same few call sites
repeated once per installation. seq_read sits in file_operations::read in
344 places:

    semcode> callers seq_read

    -Note: 134332 further call sites go through a member of the same name,
    -but nothing says their receiver has the type the function was
    -installed in.
    +Note: 392 further call sites go through a member of the same name, but
    +nothing says their receiver has the type the function was installed
    +in.

There are 392 such call sites. 134,332 was the number of pairs, which
answers a question nobody asked, and is the number the reader would have
quoted.

Group by the site. A site is one answer whichever installation the reader
looks at, so an answer names one of them and says how many there are. The
one it names is the first in the tree rather than the first row to come
back, so the same query gives the same answer twice running.

Assisted-by: claw:claude-opus-5
Signed-off-by: Rik van Riel <riel@surriel.com>
The analyzer stored the type of the base and the field for a receiver like
`file->f_op`, and nothing read the pair. Read it: look the base type up, take
what the field is declared as, and that is the receiver's type.

The lookup happens here rather than while parsing because it crosses files.
`struct file` is declared in a header the calling file includes but does not
contain, and the types table is only complete once the whole tree is
indexed.

Every definition of the base type has to agree on what the field is. Where
they conflict the receiver stays untyped, on the same grounds as everywhere
else in this series: an answer chosen between two candidates is worse than
no answer. Resolutions are cached by (type, field), since a member is asked
about once per site and a tree has many sites through the same field.

    semcode> callers seq_read

before, no answer at all, because the VFS reaches it as `file->f_op->read()`
and the receiver was untyped:

    === Direct Callers ===
    1 functions directly call 'seq_read':
      1. pstore_file_read

    Note: 392 further call sites go through a member of the same name, but
    nothing says their receiver has the type the function was installed in.

after:

    === Indirect Callers ===
    3 call sites can reach it through a function pointer:
      1. do_loop_readv_writev at fs/read_write.c:848 [member_arrow]
         installed in file_operations::read at arch/arm64/kvm/ptdump.c:204
         and 343 other places (receiver type matches)
      2. loop_rw_iter at io_uring/rw.c:733 [member_arrow]
         installed in file_operations::read at arch/arm64/kvm/ptdump.c:204
         and 343 other places (receiver type matches)
      3. vfs_read at fs/read_write.c:572 [member_arrow]
         installed in file_operations::read at arch/arm64/kvm/ptdump.c:204
         and 343 other places (receiver type matches)

    Note: 272 further call sites go through a member of the same name, but
    nothing says their receiver has the type the function was installed in.

The note falls from 392 to 272 because 120 of those sites are now known to
dispatch through some other struct. For callers of tcp_v4_rcv it falls from
35 to 25.

Assisted-by: claw:claude-opus-5
Signed-off-by: Rik van Riel <riel@surriel.com>
@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Aug 21, 2026
@rikvanriel rikvanriel changed the title Scratch/riel/funcpointers receiver typing Indirect calls: function pointers and macro bodies Aug 21, 2026
`SCHEMA_VERSION` is documented as the number to bump when the meaning of
stored data changes, and nothing reads it. This series changed that meaning
several times — dispatch sites, registrations, receiver types, facts from
macro bodies — and an index written before those patches holds none of them.

Bump it, and give callers a way to ask. `stored_schema_version` reports what
wrote the index, `index_predates_reader` answers the question a caller
actually has, and `record_schema_version` marks an index as holding what
this build writes.

An index that already has functions but no `schema_meta` table was written
before the table existed, so creating it records version 0 rather than the
current one. Stamping the current version there would be the one lie that
makes the rest useless: the index would claim to hold what it does not.

No behaviour change yet; the next patch acts on the answer.

Assisted-by: claw:claude-opus-5
Signed-off-by: Rik van Riel <riel@surriel.com>
An index written by an older semcode holds whatever that version extracted.
Queried by this one it answers with fewer callers, no registrations and no
receiver types — an answer shaped exactly like a real one, and wrong in a
way the reader cannot see. The indexer rebuilds such an index now, but only
if it is run; a reader that finds one has no reason to believe it.

Refuse, and say what to run:

    semcode> callers seq_read
    Error: this index was written by an older semcode (version 0; this build
    writes 2). It does not hold everything this build extracts, so answers
    from it would be incomplete without saying so.

    Rebuild it with:

        semcode-index -s /data/users/riel/chrony -d /tmp/semcode-stale

    or pass --reindex-if-stale to rebuild it now.

The paths are absolute because the command is meant to be pasted, and a
database is usually named relative to somewhere the reader is not standing.

Rebuilding stays opt-in. The tree that would be read is whichever one the
caller passed, which need not be the one the index was built from, so doing
it by default would let a query rewrite a database from the wrong source.
It also turns a query into an operation as long as indexing, which for a
caller with a timeout — an editor, an agent — is a worse failure than being
told to run a command. `--reindex-if-stale` is for callers that know the
tree is right and can wait, and it rebuilds with the options the index
records, or the defaults with a line saying so.

Assisted-by: claw:claude-opus-5
Signed-off-by: Rik van Riel <riel@surriel.com>

@chucklever chucklever left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The separation is right. I tried folding the references into the calls
column on a branch of my own, so that .handler = tcp_v4_rcv makes
inet_init a caller. That answers "who mentions it," and issue #9 asks who
reaches it, which is ip_protocol_deliver_rcu. It also makes calls mean two
things at once, and callchain then walks ops tables as though they were call
edges. Storing the dispatch site and the registration as separate rows and
joining them at query time is what produces the dispatcher. Grading the
evidence is what lets a reader check the answer instead of taking it.

Four things I would want fixed before this goes in.

1. Neither new table gets a scalar index

create_scalar_indices() in src/database/schema.rs:495 has no entry for
dispatch_sites or registrations. find_by_target() and find_by_member()
filter 575,240 and 72,605 rows unindexed, on your own counts.
find_indirect_callers() issues one find_by_member() per distinct member the
target sits in, so a function registered in several slots pays that cost once
per slot.

Adding entries to create_scalar_indices() will not reach a database that
already exists. That function returns early as soon as functions holds more
than 100 rows. The indices have to be created in create_dispatch_sites_table()
and create_registrations_table(), or the early return has to go per-table.
Columns that want one: registrations(target, member, container_type) and
dispatch_sites(member, target, caller_name).

2. type_chained_receivers() reads every version of the type

TypeStore::find_all_by_name() applies no manifest filter, so it returns every
struct file the database holds, including the same struct at older commits.
The rule that every definition must agree on the field then reads one struct at
two commits as a conflict, and the field resolves to None. Index a range that
spans a change to any such field and receiver typing switches off for it. Every
site that depended on it falls out of "receiver type matches" and into the
count at the bottom, and nothing says why. find_indirect_callers() is holding
the manifest already.

3. The upgrade path stamps more than it reads

run_pipeline() replaces the requested range with process_git_tree() at HEAD,
and process_git_tree() calls record_schema_version() when it finishes. A
database built from a commit range keeps version 1 rows for every commit that
is not HEAD and now claims version 2, so those files are never read again. That
is the silence your own commit message argues against, one level up. The path
also drops an explicit --git A..B after printing a line about something else.

Recording the version per file rather than per database collapses the whole
case. Put it in processed_files as a column and include it in the dedup key.
A file indexed by an older extractor then fails the skip test on its own, the
range path works, and the special case in index.rs goes away.

4. The reader never checks the version

schema.rs says a reader that does not understand a version must refuse rather
than guess. index_predates_reader() is called only from the indexer. Point
semcode at a version 1 database and implementors file_operations.read
answers "Nothing is installed," which is exactly what an empty slot looks like.
That is the failure parse_call_list() was changed to stop.

Numbers I am missing

The series reports row counts and no times. Three parse attempts per macro body
across 133,341 macros, and an unindexed scan of the registrations table on
every callers, are both places where this turns from slower into unusable.
What is the indexing wall clock before and after, and what does
callers seq_read cost on a kernel database?

The part that still hurts

I counted member call chains by depth in net/sunrpc and fs/nfsd, since that is
the code I read most:

  69   ops->m()
  95   xprt->xpt_ops->xpo_recvfrom()
   6   rqstp->rq_xprt->xpt_ops->xpo_result_payload()
   2   inode->i_sb->s_export_op->block_ops->commit_blocks()

The second row is the dominant SUNRPC form and the types table resolves it, so
the one-step limit costs 8 sites out of 172 here. Chain depth is not what hurts.

What hurts is the receiver that never gets typed at all. Your table says 41,311
of 72,605 sites end up with a receiver type, so 43% of dispatch sites can only
ever appear as the number in the note. For those, callers prints a count and
the reader goes back to grep. The note is the right thing to print. It is still
most of a large subsystem's indirection.

Typedefs split the join in a way worth calling out. aggregate_type_name()
records a typedef name as a container, so the registration is stored under it.
aggregate_of() returns None for a bare name, so a chained receiver whose
field is declared as that typedef never resolves to the same key. The two
halves cannot meet there. The kernel mostly spells out struct, so this costs
little today. It will not stay cheap in the C++ you say you are aiming at.

Then callchain. callers answers issue #9 and callchain does not. In
SUNRPC every chain worth following crosses a transport dispatch:
callchain svc_recv stops at xprt->xpt_ops->xpo_recvfrom today and still
stops there after this series. The candidates are resolved and sitting right
there by then. I would take that patch ahead of the C++ front end.

Smaller items go inline.

@chucklever chucklever left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The smaller items, as promised. None of these blocks.

I see the two commits since. query: refuse to answer from an index older than this build covers point 4 above. I will read both properly rather than comment from the subject lines.

One item has no line to anchor to: docs/schema.md documents 11 tables and this adds dispatch_sites, registrations and schema_meta. CLAUDE.md points at that file as the schema of record. docs/semcode-mcp.md wants the new tools too, since the test now asserts 19.

Comment thread src/database/schema.rs Outdated
// Empty when the site is not inside a function at all: Python
// module level and class bodies, C++ and Rust static
// initializers. file_path and line always locate it.
Field::new("caller_name", DataType::Utf8, true),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

caller_name is declared nullable and the comment above it describes sites that sit outside any function. insert_batch() calls append_value(&site.caller_name) unconditionally, and site_from_batch() reads it through text(), which calls .value(row) with no null check.

Nothing writes a null today. If something did, it would read back as an empty string, which is already what the file-scope case stores. Make the column non-null, or read it through the optional path.

Comment thread src/database/schema.rs Outdated
.to_string();
let keys = vec![
"schema_version",
"populated_since:dispatch_sites",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These two keys are written here and never read anywhere.

The doc comment above stored_schema_version() says the marks record when a feature started being populated, and that this is what makes a backfill decidable. Nothing decides a backfill from them. Wire them into the staleness check, or drop them so the comment stops describing a mechanism that is not there.

b.site_line,
))
});
found.dedup();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dedup() drops adjacent equal elements, and the sort above orders by caller_name, site_file and site_line. Entries that share those three but differ in evidence can sit between two that are fully equal, so the duplicates survive.

It bites when one site yields two identical rows, which the byte_start in the merge key allows: two dispatch sites on the same line become two rows in the table and one IndirectCaller each, since IndirectCaller carries no byte offset. Sorting on the full tuple, or collecting through a HashSet, closes it.

Comment thread src/callchain.rs
// Always use git-aware callers query
let callers = db.get_function_callers_git_aware(name, git_sha).await?;
if callers.is_empty() {
let indirect = db.find_indirect_callers(name, git_sha).await?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_function_callers_git_aware() on the line above generates a manifest, and this generates another. generate_git_manifest() walks the whole tree through walk_tree_at_commit() with no cache, so callers now costs two full tree walks per invocation. On a kernel that is roughly 90k entries, twice.

Threading one manifest down, or memoizing it on the SHA, gets it back to one.

Comment thread src/callchain.rs
}
}

if !by_name_only.is_empty() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When confident is empty, this prints with no section header above it, so the reader gets a bare Note: line hanging off the direct callers. further reads oddly there too, since nothing was listed before it.

Print the === Indirect Callers === header whenever either list is non-empty, and word the count for the case where it is the whole answer.

/// and `.short (type)` as one named `short`. Rejecting a member that is a
/// keyword drops those without needing to know which bodies are assembly
/// — the same reading is wrong wherever it happens.
fn is_c_keyword(name: &str) -> bool {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.long and .short are caught here because they happen to be C keywords. The population you are filtering is assembler directives, and most of them are not: .align, .byte, .word, .quad, .zero, .fill, .org, .set, .type, .size, .rept. .align (n) in a macro body still reads as a dispatch through a member named align.

The list is C89 and C99 only, so _Bool, _Generic, _Alignof and _Static_assert fall through as well. See the note on parse_macro_body() for a filter that covers the class rather than the instances.

Comment thread src/treesitter_analyzer.rs Outdated
return MacroBodyFacts::default();
}

let Some((tree, wrapped, prefix_len, _clean)) = Self::parse_macro_body(parser, body) else {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parse_macro_body() returns whether the parse came out clean, and this discards it. Its own doc says a body that parses with errors still yields usable call names, but that its structure is whatever error recovery invented.

sites and registrations further down are nothing but structure, and they are harvested from that tree either way. Gating those two on the clean flag while keeping calls follows the comment, and it covers the assembler case without enumerating directives.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That makes sense, but when Claude tried it, it didn't actually work out. I will fix all the other comments, and update the pull request. Analysis below:

Where I want to push back, and the data for it

One review point, comment (h) on parse_macro_body(), together with the
directive list in comment (g). The diagnosis is right: the keyword filter
catches .long and .short because they happen to be C keywords, and that is
not the class being filtered. Two remedies were proposed. Both are more
expensive than the defect, and one of them removes real answers.

All numbers are from a whole Linux tree, 1,020,780 functions, indexed with the
series applied.

What the defect actually costs

The keyword rule removes 29 dispatch sites — 27 through ., 2 through ->
— and 0 registrations. Every one is an assembler directive read as a member:
_ASM_EXTABLE_HANDLE, __ASM_EXTABLE_RAW on arm64, loongarch and riscv, the
powerpc instruction macros.

Remedy 1: harvest structure only from an error-free parse

Measured, because this is the version I built first and then rejected:

with the gate
"~/text/semcode-review-pushback.md" 91L, 4090B 1,1 Top

Where I want to push back, and the data for it

One review point, comment (h) on parse_macro_body(), together with the
directive list in comment (g). The diagnosis is right: the keyword filter
catches .long and .short because they happen to be C keywords, and that is
not the class being filtered. Two remedies were proposed. Both are more
expensive than the defect, and one of them removes real answers.

All numbers are from a whole Linux tree, 1,020,780 functions, indexed with the
series applied.

What the defect actually costs

Where I want to push back, and the data for it

One review point, comment (h) on parse_macro_body(), together with the
directive list in comment (g). The diagnosis is right: the keyword filter
catches .long and .short because they happen to be C keywords, and that is
not the class being filtered. Two remedies were proposed. Both are more
expensive than the defect, and one of them removes real answers.

All numbers are from a whole Linux tree, 1,020,780 functions, indexed with the
series applied.

What the defect actually costs

The keyword rule removes 29 dispatch sites — 27 through ., 2 through ->
— and 0 registrations. Every one is an assembler directive read as a member:
_ASM_EXTABLE_HANDLE, __ASM_EXTABLE_RAW on arm64, loongarch and riscv, the
powerpc instruction macros.

Remedy 1: harvest structure only from an error-free parse

Measured, because this is the version I built first and then rejected:

with the gate
                                                          1,1           Top

Where I want to push back, and the data for it

One review point, comment (h) on parse_macro_body(), together with the
directive list in comment (g). The diagnosis is right: the keyword filter
catches .long and .short because they happen to be C keywords, and that is
not the class being filtered. Two remedies were proposed. Both are more
expensive than the defect, and one of them removes real answers.

All numbers are from a whole Linux tree, 1,020,780 functions, indexed with the
series applied.

What the defect actually costs

The keyword rule removes 29 dispatch sites — 27 through ., 2 through ->
— and 0 registrations. Every one is an assembler directive read as a member:
_ASM_EXTABLE_HANDLE, __ASM_EXTABLE_RAW on arm64, loongarch and riscv, the
powerpc instruction macros.

Remedy 1: harvest structure only from an error-free parse

Measured, because this is the version I built first and then rejected:

with the gate
                                                          1,1           Top

Where I want to push back, and the data for it

One review point, comment (h) on parse_macro_body(), together with the
directive list in comment (g). The diagnosis is right: the keyword filter
catches .long and .short because they happen to be C keywords, and that is
not the class being filtered. Two remedies were proposed. Both are more
expensive than the defect, and one of them removes real answers.

All numbers are from a whole Linux tree, 1,020,780 functions, indexed with the
series applied.

What the defect actually costs

The keyword rule removes 29 dispatch sites — 27 through ., 2 through ->
— and 0 registrations. Every one is an assembler directive read as a member:
_ASM_EXTABLE_HANDLE, __ASM_EXTABLE_RAW on arm64, loongarch and riscv, the
powerpc instruction macros.

Remedy 1: harvest structure only from an error-free parse

Measured, because this is the version I built first and then rejected:

with the gate
                                                          1,1           Top

Where I want to push back, and the data for it

One review point, comment (h) on parse_macro_body(), together with the
directive list in comment (g). The diagnosis is right: the keyword filter
catches .long and .short because they happen to be C keywords, and that is
not the class being filtered. Two remedies were proposed. Both are more
expensive than the defect, and one of them removes real answers.

All numbers are from a whole Linux tree, 1,020,780 functions, indexed with the
series applied.

What the defect actually costs

The keyword rule removes 29 dispatch sites — 27 through ., 2 through ->
— and 0 registrations. Every one is an assembler directive read as a member:
_ASM_EXTABLE_HANDLE, __ASM_EXTABLE_RAW on arm64, loongarch and riscv, the
powerpc instruction macros.

Remedy 1: harvest structure only from an error-free parse

Measured, because this is the version I built first and then rejected:

with the gate
                                                          1,1           Top

[riel@devbig015.atn7 ~/local/semcode (scratch/riel/funcpointers-receiver-typing)]$ curl -o /tmp/feedback #50
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0curl: (7) Couldn't connect to server
[riel@devbig015.atn7 ~/local/semcode (scratch/riel/funcpointers-receiver-typing)]$ curl $(fwdproxy-config curl) -o /tmp/feedback #50
Downloading fwdproxy-config-cli...
[ ]Uses thrift-py-deprecated. Migrate to thrift-python. See https://fburl.com/thrift-python and https://fburl.com/wiki/jihy02dr. Future automatic thrift-py-deprecated code generation may stop for non-migrated targets: https://fburl.com/workplace/wer48s4m.
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 654k 0 654k 0 0 402k 0 --:--:-- 0:00:01 --:--:-- 403k
[riel@devbig015.atn7 ~/local/semcode (scratch/riel/funcpointers-receiver-typing)]$ less /tmp/feedback
[riel@devbig015.atn7 ~/local/semcode (scratch/riel/funcpointers-receiver-typing)]$
[riel@devbig015.atn7 ~/local/semcode (scratch/riel/funcpointers-receiver-typing)]$ vi ~/text/semcode-review-pushback.md
[riel@devbig015.atn7 ~/local/semcode (scratch/riel/funcpointers-receiver-typing)]$ cat !$
cat ~/text/semcode-review-pushback.md

Where I want to push back, and the data for it

One review point, comment (h) on parse_macro_body(), together with the
directive list in comment (g). The diagnosis is right: the keyword filter
catches .long and .short because they happen to be C keywords, and that is
not the class being filtered. Two remedies were proposed. Both are more
expensive than the defect, and one of them removes real answers.

All numbers are from a whole Linux tree, 1,020,780 functions, indexed with the
series applied.

What the defect actually costs

The keyword rule removes 29 dispatch sites — 27 through ., 2 through ->
— and 0 registrations. Every one is an assembler directive read as a member:
_ASM_EXTABLE_HANDLE, __ASM_EXTABLE_RAW on arm64, loongarch and riscv, the
powerpc instruction macros.

Remedy 1: harvest structure only from an error-free parse

Measured, because this is the version I built first and then rejected:

with the gate
dispatch sites −134
registrations −819

819 is 42% of everything macro-body registrations find. The reason is in the
gate's own premise: a body that fails to parse cleanly can still state plainly
which function it installs. DEFINE_PMC_CORE_ATTR_WRITE and the other
FOPS-defining macros are full of token pasting that the C grammar cannot read,
and their .read = seq_read is unambiguous anyway.

Paying 953 rows to remove 29 wrong ones is the wrong direction, when the 29 can
be removed exactly.

Remedy 2: filter the assembler directives by name

This one removes correct answers. Those names are ordinary struct members in
ordinary C, and nouveau in particular is full of them. Counting sites in the
kernel whose member is one of the proposed names, with the keyword filter
disabled so nothing is hidden:

set   131      zero   5
size   37      type   2
align   9      rept   2
fill    6      word   1

193 sites, and the ones I read are all real:

nvkm_outp_bl_set    outp->ior->func->bl.set(...)
nvkm_fb_vidmem_size fb->func->vidmem.size(...)
shadow_image        mthd->func.size(...)
nvkm_memory_size    (p)->func->size(p)

.set and .size are function-pointer members of nvkm_ior_func_bl and
nvkm_fb_func_vidmem. Filtering the directive names deletes those dispatches
to remove none of the 29, since not one of the 29 is named after a
non-keyword directive.

The predicted population does not appear. .align (n) inside an assembly
macro body was expected to read as a dispatch through align; all 9 align
sites in the tree have real receivers and real callers. Assembly bodies that
are not keyword-shaped do not survive the parse as field expressions at all.

Why the keyword rule is the right shape

It is sound rather than heuristic: C forbids a member named long, so a site
claiming one is a misparse, always, with no tree-specific judgement. The
enumerated-directive rule is unsound in both directions — it misses directives
nobody listed and deletes members that share a name with one.

What I will change

The review is right that the list is incomplete. C11 and C23 keywords are
missing: _Bool, _Generic, _Alignof, _Static_assert, _Atomic,
_Complex, _Imaginary, _Noreturn, _Thread_local, alignas, alignof,
bool, constexpr, false, nullptr, static_assert, thread_local,
true, typeof, typeof_unqual. Those go in, and the rule keeps its
justification: none of them can name a member.

A second sound signal is available and I will take it as well, though it is
small: a member dispatch with an empty receiver expression is not a
dispatch. With the keyword filter disabled, 12 sites have one, all of them
assembly (long 11, if 1). They are a subset of the 29 today, so this adds
nothing immediately — it is there for the next construct that produces
structure with nothing on the left of the dot, which is the shape both
assembler cases took.

Net: the class gets covered by two rules that cannot be wrong, instead of one
list that can be wrong twice, and no correct row is lost.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right, and the measurement settles it. 819 registrations is six times
what the gate would remove, and the reason is the one I missed: a body full of
token pasting never parses cleanly, and its .read = seq_read is unambiguous
regardless.

The directive list is worse than that. I proposed set, size and align
without checking whether they name real members, and outp->ior->func->bl.set()
says they do. Withdrawn.

One thing to carry over. parse_macro_body() still tells the reader that its
structure is whatever error recovery invented, at src/treesitter_analyzer.rs:3370.
The code now leans on that structure on purpose and you have the numbers for
why. Worth saying so there, or the next reader files the comment I just filed.

rikvanriel and others added 20 commits August 21, 2026 13:08
The tools answer a model, and a model cannot tell that an answer is short.
Given an index written by an older semcode it receives fewer callers and no
registrations, formatted exactly like a complete answer, and proceeds on it.

Refuse, as the query command does, and carry the facts as fields rather
than only as prose:

    "isError": true,
    "index_stale": true,
    "written_by": 0,
    "expected": 2,
    "command": "semcode-index -s /home/riel/linux -d /home/riel/linux"

A sentence has to be interpreted before it can be acted on, and a model
handed one will improvise: re-run the query, try another tool, or report
the absence of callers as a finding. A field it can branch on, and a
command it can run or pass to the user verbatim.

The check goes where the existing empty-database check goes, in front of
every tool that reads the index, so a tool cannot be added later that skips
it by accident.

Assisted-by: claw:claude-opus-5
Signed-off-by: Rik van Riel <riel@surriel.com>
`find_by_target()` and `find_by_member()` scan. On a Linux tree that is
575,240 registrations and 72,605 dispatch sites read in full, and
`find_indirect_callers()` issues one `find_by_member()` per distinct member
the target sits in, so a function installed in several slots pays it once
per slot.

The indices are created with the tables rather than in
`create_scalar_indices()`, which returns as soon as `functions` holds more
than 100 rows: entries added there reach neither an existing database nor a
fresh one, since the table fills before that function runs again.

Three columns each, being the ones every query filters on: the target when
asking where a function is installed, the member when joining sites to
registrations, the container type when asking what implements a slot.

Measured on a Linux tree, this is not where the time goes:

    callers seq_read      3.06 s -> 3.20 s
    callers tcp_v4_rcv    2.62 s -> 2.54 s

`callers` generates a git manifest twice per invocation, each a full tree
walk of roughly 90,000 entries, which dwarfs the scans this removes. The
next patch takes that out; these indices are what keep the table scans from
becoming the cost once it is gone.

Assisted-by: claw:claude-opus-5
Signed-off-by: Rik van Riel <riel@surriel.com>
`callers` asks for the manifest twice, once for direct callers and once for
indirect. Each ask walks the whole tree — roughly 90,000 entries on a Linux
tree — with nothing between them changing.

Keep the last one. A query asks about a single revision, so a cache of one
covers the repeat without deciding when to evict anything: the next
revision replaces it.

    callers seq_read      3.20 s -> 2.19 s
    callers tcp_v4_rcv    2.54 s -> 1.73 s
    callchain vfs_read            2.22 s

The remaining second is the query itself, not the tree walk.

Assisted-by: claw:claude-opus-5
Signed-off-by: Rik van Riel <riel@surriel.com>
`insert_batch` appends. Indexing a tree that was indexed before writes a
second row for every file it reads again, with the same path and the same
content hash as the first, and the table grows by a tree's worth of rows per
run. Nothing reads more than the content hash, so the duplicates are
invisible until something needs the row to mean one thing.

Merge on the file and its content hash instead.

Assisted-by: claw:claude-opus-5
Signed-off-by: Rik van Riel <riel@surriel.com>
A file is skipped on a later run when its content hash is already recorded,
which is right as long as reading it again would produce what it produced
before. After the extractor learns something — a table, a column, a kind of
fact — that stops being true: the file has not changed, the reading of it
has, and the row that says "seen" now protects stale rows from being
replaced.

Record the version that read each file. Nothing acts on it yet; the next
patch makes the skip test consult it.

An existing `processed_files` table has no such column, and adding one to a
table that already exists is not something creating tables covers, so the
column would be missing exactly where it matters. Start that table again
instead. Its rows say a file was read by an unknown older extractor, which
is precisely the claim that must not be trusted, and dropping them costs one
re-read of a tree that had to be re-read anyway.

Assisted-by: claw:claude-opus-5
Signed-off-by: Rik van Riel <riel@surriel.com>
The skip test asked whether a file's content had been seen, and every file
in an index built by an older semcode answers yes. Re-running the indexer
over such an index therefore read nothing and changed nothing, while the
rows it left in place were missing everything this build extracts.

Ask whether *this* extractor has read it. A row recorded by an older one
fails the test on its own account, so the file is read again wherever it is
reached from — a tree walk, a commit range, one branch — with no special
case anywhere deciding that the index as a whole needs rebuilding.

Staleness is now a question about files rather than a mark on the database.
A run over a commit range reads the files that range touches and no others,
and a mark on the database would call the whole index current on the
strength of those few, which is the failure this series keeps finding: a
check that can only pass.

Assisted-by: claw:claude-opus-5
Signed-off-by: Rik van Riel <riel@surriel.com>
An index older than the build reads its whole tree again, and that override
was applied even when the caller had named a range. `semcode-index --git
A..B` on such an index indexed HEAD instead, printed a line about the index
being old, and never mentioned that the range it was given had been dropped.

Restrict the override to the case with no range, where the caller asked for
"index this" and the tree is the honest reading of it. With a range, do what
was asked: the files outside it keep their older rows, and they stay marked
as read by an older extractor, so the reader still says the index is behind
and the next run without a range reads them.

Assisted-by: claw:claude-opus-5
Signed-off-by: Rik van Riel <riel@surriel.com>
`find_all_by_name` returns every row with that name, and the types table
holds the same struct as it was at each indexed commit. Resolution then
reads one struct at two revisions as two definitions, and the rule that they
must agree on a field turns typing off whenever the field's declared type
changed between them.

Nothing says so. The sites that depended on that field drop out of "receiver
type matches" and into the count at the bottom, which reads exactly like a
receiver that was never typed at all. Index a range spanning a change to
`struct file`'s f_op and every VFS chain goes quiet.

`find_indirect_callers` holds the manifest already, so pass it down and keep
the definitions that are at the revision in hand. Disagreement between two
structs that both exist at that revision still means what it meant: not
enough is known, so the receiver stays untyped.

Assisted-by: claw:claude-opus-5
Signed-off-by: Rik van Riel <riel@surriel.com>
Two dispatches can share a line. `a->run(); b->run();` is two sites, and a
chained receiver resolved twice is two more. The answer key held caller,
file and line, so those merged into one answer whose installation count was
the sum of both — the same miscount the grouping was added to remove, one
level down.

Key on the byte offset as well, and carry it on the answer so a caller can
tell two sites apart. On a Linux tree 291 answers were carrying more than
one site, 311 rows in total.

The sort before `dedup` gets the same treatment. It ordered by three fields
while the rows differ in more, so equal rows separated by a third survived
a pass that only drops neighbours.

Assisted-by: claw:claude-opus-5
Signed-off-by: Rik van Riel <riel@surriel.com>
Two rules cover the same class, and each is sound on its own: a member
cannot be named after a keyword, and a member call cannot come from
nothing.

The keyword list stopped at C99, so `_Generic`, `_Alignof`,
`_Static_assert`, `bool`, `constexpr` and `nullptr` fell through it. They
go in for the same reason the others are there — C will not let a member
have those names, so a site claiming one is a misparse.

A member dispatch with an empty receiver is the second signal. Every one
found in a Linux tree is assembly read as C: eleven `.long` and one `.if`,
in the exception-table macros. They are a subset of what the keyword rule
already removes today, so nothing changes now; the rule is here because the
next construct of that shape need not be keyword-named.

Filtering the assembler directives by name was the other way to cover this,
and it removes correct answers. `set`, `size`, `align`, `word` and the rest
are ordinary members: 193 sites in a Linux tree dispatch through one, in
nouveau alone `outp->ior->func->bl.set()`, `fb->func->vidmem.size()` and
`mthd->func.size()`. A list of directive names cannot tell those from the
directives, and both rules here can.

Assisted-by: claw:claude-opus-5
Signed-off-by: Rik van Riel <riel@surriel.com>
A function whose every indirect caller is a member-name match printed the
count with no heading above it:

    === Direct Callers ===
    1 functions directly call 'seq_read':
      1. pstore_file_read

    Note: 149 further call sites go through a member of the same name...

The note reads as a footnote to the direct callers, and "further" claims a
list came before it. Both are wrong: the note is the whole indirect answer.

Print the heading whenever either list has anything in it, and say "further"
only when something was listed above.

Assisted-by: claw:claude-opus-5
Signed-off-by: Rik van Riel <riel@surriel.com>
`populated_since:dispatch_sites` and `populated_since:registrations` were
written when the table was created and read nowhere. The comment above them
said the marks are what makes a backfill decidable, describing a mechanism
that did not exist.

The question they were meant to answer — which rows were indexed under which
rules — is answered per file now, by the extractor version on each row of
`processed_files`, which is also what decides whether a file is read again.
A mark on the database as a whole could not have answered it: indexing is
per file, so a database holds a feature's column while most of its rows
predate the feature.

Keep the creation timestamp, which is at least true.

Assisted-by: claw:claude-opus-5
Signed-off-by: Rik van Riel <riel@surriel.com>
The column was nullable and the comment above it described sites that sit
outside any function. Nothing writes a null: the extractor writes an empty
string for those, `insert_batch` appends the value unconditionally, and
`site_from_batch` reads it through the non-optional path, which would
panic on the null the schema allows.

The empty string is the right stored form — it is what every reader already
handles, and file_path and line locate the site regardless — so say so in
the schema instead of allowing a value that would break the reader.

Assisted-by: claw:claude-opus-5
Signed-off-by: Rik van Riel <riel@surriel.com>
docs/schema.md is named in CLAUDE.md as the schema of record and documented
eleven tables. This series adds three: dispatch_sites, registrations and
schema_meta, plus a column on processed_files that decides whether a file is
read again.

docs/semcode-mcp.md lists the tools an agent can call, and gained neither
find_implementors nor find_registrations, though the test asserting the tool
count went from 17 to 19.

Assisted-by: claw:claude-opus-5
Signed-off-by: Rik van Riel <riel@surriel.com>
`callers` joins dispatch sites to registrations to answer who reaches a
given function. A call chain needs the same join the other way round: what
does this function reach through the members it dispatches on.

Add the lookup. Sites inside the named functions, typed the way `callers`
types them — from a declaration, or through the types table for a chained
receiver — and each one carries every function installed in that member of
that type.

A site whose receiver type is unknown reaches every member of that name in
the tree, so it yields nothing here; that population is a count in the
`callers` output, not a set of answers. Slots are cached per (type, member)
because ops tables are shared, and a chain asks about the same slot once
per function that dispatches on it.

Assisted-by: claw:claude-opus-5
Signed-off-by: Rik van Riel <riel@surriel.com>
A call chain stopped at the dispatch. `callchain svc_recv` listed
svc_handle_xprt and its callees, and said nothing about the work happening
in whatever is installed in svc_xprt_ops::xpo_recvfrom — the candidates
were resolvable by then, and nothing asked.

Print them:

    === Dispatches ===
    svc_handle_xprt xprt->xpt_ops->xpo_recvfrom (net/sunrpc/svc_xprt.c:870)
       └─ svc_rdma_recvfrom
       └─ svc_tcp_recvfrom
       └─ svc_udp_recvfrom

for the function asked about and for the callees listed above, so a reader
following a chain sees where it leaves by a member rather than by name.

Three candidates are named, and a wider slot says how many it has rather
than listing them: `file->f_op->read` has 934, which is a table of contents
for the kernel, not a step in a chain. `implementors` prints the whole set
for a reader who wants it.

Assisted-by: claw:claude-opus-5
Signed-off-by: Rik van Riel <riel@surriel.com>
Re-indexing a Linux tree failed a batch at a time:

    Inserter 0 failed to insert functions: Ambiguous merge inserts are
    prohibited: multiple source rows match the same target row on
    (name = "TYPE_BASED_CASE", file_path = "...core_reloc.c", ...)

A file can define the same name twice — a static inline under two
preprocessor branches, a macro defined once per configuration — and both
are recorded. Lance rejects a merge whose batch holds two rows for one
target row, and the whole batch goes with it, so a few duplicate
definitions lose every function indexed alongside them.

Keep the first definition in the file and drop the rest before merging. A
fresh index of a Linux tree holds 1,606 fewer function rows for it: on an
empty table both rows insert, so the duplicates were there all along, one
pair per name defined twice in a file.

The defect is older than the batch that exposed it. Merging into a table
where the target row does not exist yet inserts both rows without
complaint, so a fresh index never hits it, and until this series re-running
the indexer over an existing one read no files at all. Reading them again
is what made it fire.

`types` merges on a key of the same shape and can collide the same way,
though no collision appears on a Linux tree; it gets the same treatment.
`content` and `symbol_filename` already deduplicate before merging.

Assisted-by: claw:claude-opus-5
Signed-off-by: Rik van Riel <riel@surriel.com>
find_callchain stopped where the callchain command used to: at the member
call. An agent following a chain through SUNRPC saw svc_handle_xprt and its
callees, and nothing about svc_xprt_ops::xpo_recvfrom being where the work
happens.

Print the same section the command prints, for the function asked about and
the callees listed with it.

Also correct docs/semcode-mcp.md, which described find_implementors as
taking `name: type.member`. It takes container_type and member, and the
wrong spelling answers "nothing is installed in that member" — a wrong
argument reading exactly like an empty slot.

Assisted-by: claw:claude-opus-5
Signed-off-by: Rik van Riel <riel@surriel.com>
The two halves of the join disagreed about typedefs. Registrations are filed
under whatever the file declares, and `aggregate_type_name` accepts a bare
name, so `.set = riva_set` inside a `RIVA_HW_INST` initialiser is stored
under `RIVA_HW_INST`. Resolution then refused a bare name outright, so a
receiver reaching that member through a field declared `RIVA_HW_INST` never
arrived at the same key.

Accept the name. A typedef of a struct is a container something is
registered under, and a name that names nothing joins nothing, so the
filtering the join already does is enough.

Builtins are dropped rather than passed through: nothing is registered
under `int` or `u32`, and a member declared as one has nothing to dispatch
through. A function-pointer member is dropped as before, being a signature
rather than an aggregate.

Small on a Linux tree, which spells out `struct`: of 22,042 chained
receivers, 21,511 read a field declared as an aggregate and 116 read one
declared as a typedef, 37 of them naming a container something is installed
in. The C++ this is meant to extend to writes the other way round.

Assisted-by: claw:claude-opus-5
Signed-off-by: Rik van Riel <riel@surriel.com>
`display->parent->dsb->read()` was left untyped. The analyzer recorded the
base and one field, resolution looked that field up, and a chain of two was
outside what either could say — so the site fell into the count at the
bottom of `callers` alongside receivers nothing is known about.

Record the whole path of fields, and walk it: the type of `parent` decides
where to look for `dsb`. Each hop is the lookup that was already there, with
the same rule that every definition of a type at this revision must agree on
what a field is declared as.

A part of the chain that is not a plain name still records nothing. A call
in the middle, `common(ah)->ops->read()`, needs the return type of a
function rather than the type of a field; reading the rest of the chain
without it would file the site under whatever the last field happened to be.

On a Linux tree 7,965 of 30,007 chained receivers read more than one field,
and `callers seq_read` reports 152 same-named sites where it reported 272:
120 of them are now known to dispatch through some other struct.

This changes what `receiver_field` holds, so the schema version goes to 3
and an index written by version 2 is read again.

Assisted-by: claw:claude-opus-5
Signed-off-by: Rik van Riel <riel@surriel.com>
@rikvanriel

Copy link
Copy Markdown
Author

Four things I would want fixed before this goes in.

Thank you for flagging these issues, Chuck! While addressing the issues you flagged, a whole bunch of other corner cases were discovered, and fixed along the way.

Here is the changelog since your first review:

Every example below is real output. before is the binary at the series
base, after is the tip, both against an index of the same Linux tree.


1. Answering the review

Four blockers and eight inline comments, all addressed:

  • Scalar indices on both new tables, created with the tables rather than
    in create_scalar_indices(), which returns as soon as the database holds
    rows and so would have reached neither an existing index nor a fresh one.
  • Chained receivers resolve at the queried revision. The types table
    holds the same struct at every indexed commit; reading all of them made two
    revisions of one struct look like two definitions disagreeing, which turned
    typing off for that field silently.
  • The extractor version lives on each file, in processed_files, inside
    the dedup key — replacing the database-wide mark and, with it, three
    special cases. --git A..B is honoured again rather than being quietly
    replaced by a tree read.
  • A stale index is refused, not answered from — by the query command and
    by MCP, which returns it as fields (index_stale, written_by, expected,
    command) rather than prose for a model to interpret.
  • Smaller: the answer key includes the byte offset, so two dispatches on one
    line stop merging into one answer with both their counts; the keyword list
    covers C11 and C23; a member call with no receiver is rejected; the indirect
    heading prints whenever there is anything indirect; caller_name is
    declared as what is stored in it; the marks nothing read are gone; the three
    new tables and two new tools are documented.

One review suggestion was declined with a measurement: filtering assembler
directives by name would delete 193 correct sites, because set, size,
align and word are ordinary members — outp->ior->func->bl.set() in
nouveau. The keyword rule covers the same population soundly. Detail in
semcode-review-pushback.md.

2. Defects found by running it

  • Re-indexing an existing database failed a batch at a time. A file can
    define one name twice, lance rejects a merge holding two rows for one target
    row, and the whole batch is lost with it. Invisible until now because a fresh
    index has no target row to collide with, and re-indexing skipped every file.
    A fresh index of Linux also holds 1,606 fewer function rows for the fix.
  • A macro's dispatch was recorded as a call to its member.
    #define nvkm_memory_target(p) (p)->func->target(p) claimed a call to
    target, which is not a function. The scan that rescues unparsable bodies
    ran whenever no callee was named, and re-read the member as one.
  • callers walked the whole tree twice for two manifests, once for direct
    callers and once for indirect.

Both harness blind spots behind these are closed: the regression gate now
re-indexes an existing database as well as building a fresh one.

3. New capability

  • Chained receivers of any depth. display->parent->dsb->read() records
    the whole path and resolution walks it. 7,965 of 30,007 chained receivers on
    a Linux tree read more than one field.
  • A typedef is a container. Registrations were filed under typedef names
    while resolution refused them, so the two halves of the join could not meet.
  • Call chains no longer stop at a dispatch, in the command and in MCP.

4. Performance

callers seq_read      3.06 s -> 2.19 s
callers tcp_v4_rcv    2.62 s -> 1.73 s
indexing Linux        79.3 s -> ~90 s   (+13%, for everything above)

Before and after, in the REPL

The question in issue #9

semcode> callers tcp_v4_rcv

before:

Info: No functions call 'tcp_v4_rcv'

after:

=== Indirect Callers ===
1 call sites can reach it through a function pointer:
  1. ip_protocol_deliver_rcu at net/ipv4/ip_input.c:207 [macro_declared]
     names it at the call site

Note: 27 further call sites go through a member of the same name, but
nothing says their receiver has the type the function was installed in.

The VFS shape

semcode> callers seq_read

before:

=== Direct Callers ===
1 functions directly call 'seq_read':
  1. pstore_file_read

after:

=== Direct Callers ===
1 functions directly call 'seq_read':
  1. pstore_file_read

=== Indirect Callers ===
3 call sites can reach it through a function pointer:
  1. do_loop_readv_writev at fs/read_write.c:848 [member_arrow]
     installed in file_operations::read at arch/arm64/kvm/ptdump.c:204
     and 343 other places (receiver type matches)
  2. loop_rw_iter at io_uring/rw.c:733 [member_arrow]
     installed in file_operations::read at arch/arm64/kvm/ptdump.c:204
     and 343 other places (receiver type matches)
  3. vfs_read at fs/read_write.c:572 [member_arrow]
     installed in file_operations::read at arch/arm64/kvm/ptdump.c:204
     and 343 other places (receiver type matches)

Note: 152 further call sites go through a member of the same name...

vfs_read writes file->f_op->read(...). The calling file proves file is a
struct file; the types table says f_op is a struct file_operations *.

A call chain through a transport

semcode> callchain svc_recv

before: the chain listed svc_handle_xprt and its callees, and stopped. Nothing
said the work happens in whatever is installed in xpo_recvfrom.

after, at the end of the chain:

=== Dispatches ===
svc_handle_xprt xprt->xpt_ops->xpo_accept (net/sunrpc/svc_xprt.c:852)
   └─ svc_rdma_accept
   └─ svc_tcp_accept
   └─ svc_udp_accept
svc_handle_xprt xprt->xpt_ops->xpo_recvfrom (net/sunrpc/svc_xprt.c:870)
   └─ svc_rdma_recvfrom
   └─ svc_tcp_recvfrom
   └─ svc_udp_recvfrom

A slot with many implementations reports its count instead of listing them:
file->f_op->read has 934.

Two questions that could not be asked

semcode> implementors file_operations.read

before:

Error: Unknown command: 'implementors'. Type 'help' for available commands.

after:

=== Implementors ===
1443 installed:
  1. seq_read at .../cn20k/debugfs.c:160 in __OCTEONTX2_DEBUGFS_ATTRIBUTE_FOPS
  2. dev_read at drivers/net/ethernet/mellanox/mlx5/core/lib/sd.c:772
  ...

semcode> registrations tcp_v4_rcv

before:

Error: Unknown command: 'registrations'. Type 'help' for available commands.

after:

=== Registrations ===
1 places install it:
  1. net_protocol::handler at net/ipv4/af_inet.c:1934 in inet_init

A macro body is code

semcode> func wait_event

before:

Declaration:  wait_event(void)
[definition]
Called By: 416

after:

Declaration:  wait_event( wq_head,  condition)
[definition]

Calls: 2
  → __wait_event
  → might_sleep

Called By: 416

416 callers led nowhere before, because the chain stopped at the macro. Across
a Linux tree 133,341 macros gained the 214,769 parameters they declare.

A macro that dispatches

semcode> func nvkm_memory_target

#define nvkm_memory_target(p) (p)->func->target(p)

Neither before nor after prints a Calls: line, for different reasons. Before,
macro bodies were text. After, target is recorded as a dispatch through a
member — not as a call to a function that does not exist. 800 dispatch sites
in a Linux tree are written inside a macro body, and 1,955 registrations.

An index older than the binary

New since the first posting, and it has no before: an index built by an older
semcode used to answer, shortened, without saying so.

semcode> callers seq_read
Error: this index was written by an older semcode (version 0; this build
writes 3). It does not hold everything this build extracts, so answers
from it would be incomplete without saying so.

Rebuild it with:

    semcode-index -s /home/riel/linux -d /home/riel/linux

or pass --reindex-if-stale to rebuild it now.

Running semcode-index over such an index reads every file again by itself,
rather than skipping them all as unchanged.


Coverage, and what is left

Of 52,702 member dispatch sites in C on a Linux tree, 92.8% carry a receiver
type
. The 3,818 that do not are mostly receivers naming a global the file
does not declare; semcode indexes functions, types and macros, not variables.
A receiver that comes back from a call is 65 sites tree-wide, measured rather
than assumed, and is not worth a schema change.

Rust is the larger untyped population — 17,380 member calls, all of them,
because the typing pass reads C declarations. That is the object-oriented work
described in the plan: impls as registrations, method calls as dispatch sites,
the same (container, member) key.

@chucklever

Copy link
Copy Markdown
Contributor

I am thrilled to see virtual function support moving. This has been open since
December, I put my own attempt aside because tree-sitter would not carry it,
and callers tcp_v4_rcv now answers with ip_protocol_deliver_rcu. That is
the question issue #9 asked. The dispatch line at the end of
callchain svc_recv is the one I have wanted in SUNRPC review for years.

I checked the four blockers against 0eec120 rather than against the changelog.
The indices are created with the tables, chained receivers resolve at the
queried revision, and processed_files carries the extractor version. One path
did not come along with it.

The range path still skips by content alone

process_git_tree() builds its skip set from processed_by_this_extractor(),
at src/git_range.rs:1123. process_git_range() still builds it from
get_all_processed_files(), at src/git_range.rs:1218. Only one of the two was
converted.

That did no harm while index.rs sent every stale index through a tree read.
index: honour --git on an index older than this build narrowed the redirect
to args.git.is_none(), at src/bin/index.rs:1565, which is right on its own
terms. The two together open this:

Take an index written by version 1 and run semcode-index --git A..B. The
redirect does not fire, because a range was named. process_git_range() loads
every processed file whatever extractor read it, so every file whose content
has not changed is skipped, and none of them gains a dispatch site or a
registration. record_index_build() at src/git_range.rs:1360 then stamps
schema_version 2. index_predates_reader() is false from that point on, so
nothing reads those files again, and the query command and MCP answer from them
without saying anything is missing.

That is the failure the per-file version was added to close, surviving in the
path the --git commit opened. Line 1218 wants processed_by_this_extractor().

semcode-lsp does not check

query.rs and semcode-mcp.rs both refuse a stale index. semcode-lsp.rs has no
reference to index_predates_reader(). It answers the same questions from the
same database, and an editor is where a wrong answer is least likely to get
questioned.

@rikvanriel

Copy link
Copy Markdown
Author

Thanks for spotting those last two issues, Chuck!

I'm working addressing them now.

`process_git_tree` asks `processed_by_this_extractor()` which files may be
skipped. `process_git_range` still asked `get_all_processed_files()`, so a
row written by an older extractor counted as processed there. Only one of
the two paths was converted.

That was harmless while every stale index was redirected through a tree
read. Restricting the redirect to runs that name no range left the other
path exposed: `semcode-index --git A..B` on an index written by an older
build skipped every file whose content had not changed, which is all of
them, and read nothing.

The range path also recorded the schema version when it finished. A range
reads the commits it was given, which is not the same as reading the index:
files outside those commits keep whatever an older extractor left. Stamping
the whole index as current on the strength of a range is the overstatement
this series exists to remove, so only the tree path records it now; the rows
a range writes carry their own version and speak for themselves.

Reported by Chuck Lever.

Assisted-by: claw:claude-opus-5
Signed-off-by: Rik van Riel <riel@surriel.com>
`query.rs` and `semcode-mcp.rs` both refuse an index written by an older
semcode. `semcode-lsp.rs` had no reference to `index_predates_reader()` and
answered the same questions from the same database.

An editor is where a short answer is least likely to be questioned: goto
definition moves the cursor and the reader believes it. Refusing matters
more there, not less. Definition and references now answer nothing while
the index is behind, and log why.

An index that cannot be read at all is a different thing from one that is
old, so a failed check lets the request through and lets whatever is wrong
surface on its own.

Reported by Chuck Lever.

Assisted-by: claw:claude-opus-5
Signed-off-by: Rik van Riel <riel@surriel.com>
`parse_macro_body` told the reader that a body parsing with errors yields
usable call names but that its structure is whatever error recovery
invented. Sites and registrations are nothing but structure, and they are
harvested from that tree on purpose. A comment arguing against the code it
sits on invites the next reader to correct the code.

Say what the measurement says: gating structure on a clean parse costs 819
registrations and 134 dispatch sites on a Linux tree to remove 29 wrong
rows, because a body full of token pasting never parses cleanly and its
`.read = seq_read` is unambiguous anyway. Name what filters instead — a
member cannot be named after a keyword, and a member call cannot come from
nothing — since between them they cover the assembler bodies that invented
structure was coming from.

The clean flag goes with it. It was returned for a gate that was built,
measured and dropped, and nothing has read it since.

Reported by Chuck Lever.

Assisted-by: claw:claude-opus-5
Signed-off-by: Rik van Riel <riel@surriel.com>
@rikvanriel

Copy link
Copy Markdown
Author

The latest version pushed (commit b8dccd6 at the top) should address your most recent comments.

I discovered some other things to fix in semcode while getting those commits fixed, but that's for another PR...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Make semcode understand macros better RFE: Make semcode understand function pointers

2 participants