diff --git a/plugins/axiomcode/skills/axiomcode/SKILL.md b/plugins/axiomcode/skills/axiomcode/SKILL.md index 6a7b7a22..29f06c81 100644 --- a/plugins/axiomcode/skills/axiomcode/SKILL.md +++ b/plugins/axiomcode/skills/axiomcode/SKILL.md @@ -71,7 +71,7 @@ An answer's label is the **worst** rung on its route. Read it before acting on t | `[defines]` · `[protocol]` · `[decorator by name]` | closure from its definer · interpreter-called method · wrapper rebinding the name | | `[fixture]` · `[at import]` | injected before the test body · module raised on import, test never collected | | `[spawns]` | the test runs the script as a child process, joined through the **path** it names — not an edge | -| `[by key]` | joined through a registration **string** (route, signal, CLI command) — not an edge | +| `[by key]` | joined through a registration **string** (route, signal, CLI command, the event type a handler table is keyed by) — not an edge | | `[stubs it]` | a call written inside a mock's stub or verification (`when(m.f())`, `verify(m).f()`, `Setup(x => x.F())`, `Received().F()`): names it, runs none of it — never a test route, listed apart | | `[in scope]` · `[by name]` · `[text]` | same name in the owner's scope · same name elsewhere (may be another thing) · text only | | `[alongside]` | declared in the same type or file — no call, no reference; its own section (`alongside` in `--json`), never a dependent | diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py index c0d82607..2f892c57 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py @@ -16,6 +16,7 @@ here: the reference alone says it is passed as a value (`valueref` in dl/impact.dl), and naming the receiving call as one that "calls it where the graph cannot follow" was wrong for every synchronous collection operation (#1166). """ +import collections import re ROUTE_VERB = {'get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace', 'connect', 'all', 'use', 'route'} @@ -990,8 +991,150 @@ def route_candidates(written, registered_keys): def all_registrations(q, site_file=None): - """Every (decl, file, line, kind, key, why) this module can derive, from all three sources.""" - return sorted(set(registrations(q, site_file) + decoration_keys(q, site_file) + value_route_registrations(q, site_file))) + """Every (decl, file, line, kind, key, why) this module can derive, from all four sources.""" + return sorted(set(registrations(q, site_file) + decoration_keys(q, site_file) + value_route_registrations(q, site_file) + + table_registrations(q, site_file))) + + +# ── a HANDLER TABLE: a declaration registered under the KEY of the entry that holds it ────────────────────────── +# One service publishes an event by its type (`bus.publish(TOPICS.CREATED, doc)`, `emit("doc.created", …)`); another +# holds a table of handlers keyed by the same string (`{ [TOPICS.CREATED]: onCreated }`, `{ 'doc.created'(env) {…} }`, +# `{"doc.created": on_created}`) and a consumer looks the handler up by the message's type (`handlers[type](env)`). +# The graph has both ends and the lookup is a computed member, so nothing joined the publisher to the handler: impact +# of the producing method missed every consumer, and the tests that publish the type never reached the handler. +# It is a registration like a route: the entry's key is what the dispatcher dispatches on. Two facts are read here: +# the table entry a callable DECLARED on the entry's own line, right after its key (`[K]: function …`, `[K]: (e) =>`, +# `'k'(e) {`, `"k": lambda e: …`), or a callable NAMED as the entry's whole value (`[K]: onCreated,`). +# A key is a string literal or a constant reference resolved to one; an entry whose value is an +# array, a call's result or a schema is data, not a handler, and registers nothing. +# the constant `TOPICS.CREATED` is the string its declaration gives it (`export const TOPICS = { CREATED: +# 'doc.created' }`, `class Topics: CREATED = "doc.created"`, `static final String CREATED = …`), +# kept only when every declaration of that name agrees. A key written through a constant is a +# write of the string; the constant's own declaration and another table's key position are not. +_IDENT = r'[A-Za-z_$][\w$]*' +_CONST_REF = rf'{_IDENT}(?:\.{_IDENT})+' +_KEY_STR = r"""(?P['"])([^'"\\\s]{1,120})(?P=qt)""" # named: its group number differs in each pattern +# the start of a function value: `function`, `async (e) =>`, `e =>`, `(e) =>`, a Python `lambda` +_FN_START = rf'(?:async\s+)?(?:function\b|lambda\b|\(|{_IDENT}\s*=>)' +# the key of an entry that DECLARES its handler on this line: `[K]: `, `[K](…) {`, `'k': `, `'k'(…) {`, and a +# Python dict's `Topics.K: lambda …`. Method shorthand may carry `async` / `static` / `*`. +_ENTRY_DECL = re.compile(rf"""^\s*(?:(?:async|static|get|set)\s+|\*\s*)*(?:(?:\[\s*({_CONST_REF}|{_IDENT})\s*\]|{_KEY_STR})\s*(?::\s*{_FN_START}|\()|({_CONST_REF})\s*:\s*{_FN_START})""") +# an entry whose WHOLE value names a handler: `[K]: onCreated,` / `'k': handlers.onCreated,` / `"k": on_created,` +_ENTRY_REF = re.compile(rf"""^\s*(?:\[\s*({_CONST_REF}|{_IDENT})\s*\]|{_KEY_STR}|({_CONST_REF}))\s*:\s*(?:this\.|self\.)?({_IDENT}(?:\.{_IDENT})*)\s*,?\s*(?:\}}\s*[,;)]*\s*)?$""") +# a string constant: an object literal's `K: 'v'` (one per line or several on one), and a declaration `K = 'v'` +_CONST_ENTRY = re.compile(rf"""(?:^|[{{,])\s*({_IDENT})\s*:\s*{_KEY_STR}\s*(?=,|\}}|$)""") +_CONST_DECL = re.compile(rf"""(?:^|\s)({_IDENT})\s*(?::\s*[\w.<>\[\]]+\s*)?=\s*{_KEY_STR}\s*[;,]?\s*$""") +# a key POSITION, not a write: the quoted key or the constant is followed by `:` (an entry, a `case`), `(` (a method +# shorthand) or `]` and then `:` / `(` / `=` (a computed key, a C# index initializer) +_KEY_POS = re.compile(r'\s*(?::(?!:)|\(|\]\s*[:(=])') + + +def string_constants(q, read=None): + """({'TOPICS.CREATED': 'doc.created', 'CREATED_TYPE': 'doc.created', …}, {(file, line)}): the string each constant + name denotes, where every declaration of the name agrees, and the lines that declare them (a literal there is the + constant's definition, not a write of its value).""" + if not _has(q, 'symbols'): + return {}, set() + read = read or _source_reader(q) + seen = collections.defaultdict(set) + pos = set() + types = {i: n for i, n in q("SELECT id, name FROM symbols WHERE id IS NOT NULL AND method_id IS NULL AND type_id IS NOT NULL")} + for n, f, a, b, owner, kind in q("""SELECT name, file, line, end_line, owner, kind FROM symbols + WHERE method_id IS NULL AND name IS NOT NULL AND file IS NOT NULL AND line > 0 + AND kind NOT IN ('class', 'interface', 'enum', 'record', 'struct', 'module', 'type', 'namespace')"""): + L = read(f) + if not L or not re.fullmatch(_IDENT, n): continue + b = max(a, min(b or a, a + 400, len(L))) + oname = (types.get(owner) or (owner or '').split('.')[-1]) if owner else '' + m = _CONST_DECL.search(L[a - 1]) if a <= len(L) else None + if m and m.group(1) == n and b == a: + seen[f'{oname}.{n}' if oname else n].add(m.group(3)); pos.add((f, a)) + continue + for ln in range(a, b + 1): + for k, _qt, v in _CONST_ENTRY.findall(L[ln - 1]): + seen[f'{n}.{k}'].add(v); pos.add((f, ln)) + return {k: next(iter(vs)) for k, vs in seen.items() if len(vs) == 1}, pos + + +def _entry_key(m, consts): + """the string a matched entry is keyed by: its literal, or the constant it names resolved; None when unknown""" + ref, lit, cref = m.group(1), m.group(3), m.group(4) + if lit: return lit + return consts.get(ref or cref) + + +def table_registrations(q, site_file=None, consts=None): + """[(decl, file, line, 'table', key, why)] — a callable registered in a handler table under the entry's key. + An entry in a test file is a fixture's table, and is not what the application dispatches on.""" + if not _has(q, 'symbols'): + return [] + sf = site_file or (lambda x: x) + read = _source_reader(q) + consts = string_constants(q, read)[0] if consts is None else consts + why = lambda key: f'registered in a handler table under "{key}" here — whoever dispatches the table by that key calls it, no call site does' + out = set() + by_line = collections.defaultdict(list) + for i, f, l in q("""SELECT id, file, line FROM symbols WHERE method_id IS NOT NULL AND file IS NOT NULL AND line > 0 + AND (is_test IS NULL OR is_test = 0) AND kind NOT IN ('module', 'constructor')"""): + by_line[(f, l)].append(i) + for (f, l), ids in by_line.items(): + L = read(f) + if not L or l > len(L) or len(ids) != 1: continue + m = _ENTRY_DECL.match(L[l - 1]) + key = _entry_key(m, consts) if m else None + if key: out.add((ids[0], sf(f), l, 'table', key, why(key))) + # an entry whose value NAMES the handler: the declaration a name identifies uniquely, as `registrations()` requires + if _has(q, 'refs'): + once = {} + for n, i, c in q("""SELECT name, min(id), count(*) FROM symbols WHERE method_id IS NOT NULL AND name IS NOT NULL + AND name NOT LIKE '<%' GROUP BY name"""): + if c == 1: once[n] = i + tf = {x for (x,) in q("SELECT DISTINCT file FROM symbols WHERE is_test = 1 AND file IS NOT NULL")} + for n, f, l in q("SELECT DISTINCT name, file, line FROM refs WHERE line > 0"): + if n not in once or f in tf: continue + L = read(f) + if not L or l > len(L): continue + m = _ENTRY_REF.match(L[l - 1]) + if not m or m.group(5).split('.')[-1] != n: continue + key = _entry_key(m, consts) + if key: out.add((once[n], sf(f), l, 'table', key, why(key))) + return sorted(out) + + +def table_key_writes(q, keys, consts=None, cpos=None): + """[(value, file, line)] — where a handler-table key in `keys` is WRITTEN: a literal of it, or a constant that + resolves to it, outside a key position and outside the constant's own declaration. What a table's key is joined to.""" + if not keys: + return [] + read = _source_reader(q) + if consts is None or cpos is None: + consts, cpos = string_constants(q, read) + out = set() + def written(text, token): + for mm in re.finditer(re.escape(token), text): + # a constant is not the tail of a longer name (`MY_TOPICS.X`) or the head of a longer chain (`TOPICS.X.y`) + if token[0] not in '\'"`' and (re.match(r'[\w$]', text[mm.start() - 1:mm.start()] or ' ') + or re.match(r'[\w$.]', text[mm.end():mm.end() + 1] or ' ')): continue + if not _KEY_POS.match(text, mm.end()): return True + return False + if _has(q, 'literals'): + for v, f, l in q("SELECT value, file, line FROM literals WHERE line > 0 AND value IS NOT NULL"): + if v not in keys or (f, l) in cpos: continue + L = read(f) + text = L[l - 1] if L and l <= len(L) else None + if text is None or written(text, f"'{v}'") or written(text, f'"{v}"') or written(text, f'`{v}`'): + out.add((v, f, l)) + names = collections.defaultdict(set) # last segment -> the constants it may end + for c, v in consts.items(): + if v in keys: names[c.split('.')[-1]].add(c) + if names and _has(q, 'refs'): + for n, f, l in q("SELECT DISTINCT name, file, line FROM refs WHERE line > 0"): + if n not in names or (f, l) in cpos: continue + L = read(f) + if not L or l > len(L): continue + for c in names[n]: + if written(L[l - 1], c): out.add((consts[c], f, l)) + return sorted(out) # ── the same join the rules make, for a caller that has no Datalog ─────────────────────────────────────────── @@ -1012,16 +1155,22 @@ def key_edges(q, at, site_file=None, cap=None, use_cap=None): if not _has(q, 'literals'): return [] reg = collections.defaultdict(set) - for decl, _f, _l, _kind, key, _why in all_registrations(q, site_file): - if decl and key: reg[key].add(decl) + table = collections.defaultdict(set) # a handler table's key -> the declarations it registers + for decl, _f, _l, kind, key, _why in all_registrations(q, site_file): + if decl and key: + reg[key].add(decl) + if kind == 'table': table[key].add(decl) if not reg: return [] writes = collections.defaultdict(set) - for v, f, l in q("SELECT value, file, line FROM literals WHERE line > 0 AND value IS NOT NULL"): + for v, f, l in key_writes(q, set(table)): if not isinstance(v, str) or len(v) > 160: continue c = at(f, l) - if c: writes[v].add(c) - capped = {k for k, ds in reg.items() if len(ds) > cap} | {k for k, cs in writes.items() if len(cs) > use_cap} + if c and c not in table.get(v, ()): writes[v].add(c) + # the rules' table_key: a table key's writers in test files drive its handler and are not counted against it + tests = {i for (i,) in q("SELECT id FROM symbols WHERE is_test = 1 AND method_id IS NOT NULL")} if table else set() + capped = {k for k, ds in reg.items() if len(ds) > cap} | {k for k, cs in writes.items() + if len(cs - tests if k in table else cs) > use_cap} out = set() for v, callers in writes.items(): for key in route_candidates(v, reg): @@ -1030,3 +1179,14 @@ def key_edges(q, at, site_file=None, cap=None, use_cap=None): for d in reg[key]: if c != d: out.add((c, d, key)) return sorted(out) + + +def key_writes(q, table_keys=None): + """[(value, file, line)] — every string a callable writes that a registration key may be joined to: the literals, + except that a handler table's key is written where table_key_writes says (a dotted literal or a constant, never + the table's own key position or the constant's declaration).""" + if table_keys is None: + table_keys = {r[4] for r in table_registrations(q)} + rows = [(v, f, l) for v, f, l in q("SELECT value, file, line FROM literals WHERE line > 0 AND value IS NOT NULL") + if v not in table_keys] if _has(q, 'literals') else [] + return rows + table_key_writes(q, table_keys) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index a2b15125..8d2b5ef4 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -153,8 +153,10 @@ def ckey(k): # framework's own registration, so it outranks every name match, and it is not a call to the declaration. # `stubs it` (a call inside a mock's stub or verification) is the engine's resolution, so it outranks every name match, # and runs nothing, so it sits below every hop that does: a rename breaks it, a body change never does. +# `by key` (a handler table's entry under the type a publisher writes, dl/impact.dl) is joined on a string both ends +# spell and nothing the engine resolved: the weakest hop after a name match, as it is in the closure. CERT = {'resolved': 0, 'one of a set': 1, 'registered': 2, 'capped set': 3, 'remote': 4, 'framework': 5, - 'stubs it': 6, 'in scope': 7, 'by name': 8, 'text': 9, 'alongside': 10} + 'stubs it': 6, 'in scope': 7, 'by name': 8, 'by key': 8.5, 'text': 9, 'alongside': 10} def also_text(whys, shown=2): """the other reasons a row's callable has, said on the same line: `; also: ` (one row per dependent)""" if not whys: return '' @@ -1099,7 +1101,7 @@ class Impact: W('cs_fixture_type', sorted(x for x in fixt if not x[0].startswith('collection:'))) # ── facts: the graph, exported once (reused while graph.sqlite is unchanged) ──────────────────────────────── - IMPACT_VERSION = '55' # 55: cs_data_source, cs_data_type, cs_fixture_type, the C# test links a runner makes from a data attribute or a class/collection fixture (#1498, #1499); 53: implicit_new, the type a C# `new T()` constructs where T writes no constructor (#1473); 52: test_method holds a method under a composed or derived test marker declared in the repository (a Java annotation meta-annotated @Test, a C# attribute derived from FactAttribute: #1418, #1497; 51 was the C# test-links branch's number, landed as 55); 48: sigtype, a parameter / return position type_use resolves to a type, read before the textuse grep (#1422), and persist_field, the properties a persistence query reads (#1461); 47: test_method / fixture from graph_sql's one classification (a tear-down such as @AfterEach or [TestCleanup] is a fixture, [TestInitialize] is no test, an @Override is no named test: #1417 #1419 #1502), and reg_key_fact drops a string a decoration does not register under (#1413); 46: accessor carries the wither and an isX boolean's setX / withX, and a generated builder or fluent setter the engine resolved is a writer (#1404, #1409); 45: runs_before, a C# set-up an NUnit [SetUpFixture] or an MSTest assembly initializer runs for tests outside its type (#1501), stub rows for a member a Moq Protected() setup names by string (#1540), cs_config_literal for a Section:Key configuration key (#1443), and lex_parent puts a lambda under the declaration on its own line (#1556); 44: a C# MEMBER_ACCESS ref is qualified, so its qualifier decides (#1445); 41: spawns_fact, a test that runs a script by its path (ax_spawn.py); 40: test_method holds a script test's module (a test-tree file run as a program, no framework: graph_sql.script_tests); 39: a chained route link's `calls` row and `registration` label sit on the link's own line, with its own verb and path; 38: reg_key_fact drops a decoration string with a space in it (a description, not a key); 37: via_base / via_site, a caller that reaches a declaration through a base it is override-equivalent to (#1542), and injected_bean, the bean an injection point was wired to (#1384); 36: handoff_at, route_arg, callable_const, init_wrapper, init_alias, returns_fn — a const holding a wrapped handler registered at a route; 35: 0.1.5's 33 (#1598, the defines edges the path export links) joined 0.1.6's 33, two different fact sets under one number; 33 (0.1.6): calls carries the tier "stub" for a call inside a mock's stub or verification, reg_verb / lit_verb join a route by its HTTP method, and a handler's route joins its type's prefix; 32: cert_tier's why is worded per tier (an event_dispatch row says it sends the request or event), and the route facts #1633 changed (#1510), which merged without a bump; 31: event_dispatch edges (a published event reaches its listeners, #1391) and the pytest fixture_injection reading (#1631) change impact's facts; 30: registers, a bean another class's annotation registers (#1396, #1414); 29: the edges it links from the path export changed (#1402), and a cache written before it must not survive; 28: reexport_from, the file an `export *` line re-exports; 27: framework, the engine's framework_edge (#1509); 24: the test* naming convention requires a test class as owner (#1181); 23: owner/member disambiguated by file, two classes of one name no longer merging (#1188); 22: lex_parent, the innermost declaration enclosing each one by span (#1183); 21: cert_tier, the tier -> certainty table the call rules join on (#1131); 20: faccess, the engine's resolved field accesses (#1071); 3: decl_file facts (the import-time test-file rule); 14: the registration-key + IMPACT_VERSION = '56' # 56: reg_key_fact carries a handler table's entries (kind table), literal a table key written as a dotted string or through a constant, and test_code; 55: cs_data_source, cs_data_type, cs_fixture_type, the C# test links a runner makes from a data attribute or a class/collection fixture (#1498, #1499); 53: implicit_new, the type a C# `new T()` constructs where T writes no constructor (#1473); 52: test_method holds a method under a composed or derived test marker declared in the repository (a Java annotation meta-annotated @Test, a C# attribute derived from FactAttribute: #1418, #1497; 51 was the C# test-links branch's number, landed as 55); 48: sigtype, a parameter / return position type_use resolves to a type, read before the textuse grep (#1422), and persist_field, the properties a persistence query reads (#1461); 47: test_method / fixture from graph_sql's one classification (a tear-down such as @AfterEach or [TestCleanup] is a fixture, [TestInitialize] is no test, an @Override is no named test: #1417 #1419 #1502), and reg_key_fact drops a string a decoration does not register under (#1413); 46: accessor carries the wither and an isX boolean's setX / withX, and a generated builder or fluent setter the engine resolved is a writer (#1404, #1409); 45: runs_before, a C# set-up an NUnit [SetUpFixture] or an MSTest assembly initializer runs for tests outside its type (#1501), stub rows for a member a Moq Protected() setup names by string (#1540), cs_config_literal for a Section:Key configuration key (#1443), and lex_parent puts a lambda under the declaration on its own line (#1556); 44: a C# MEMBER_ACCESS ref is qualified, so its qualifier decides (#1445); 41: spawns_fact, a test that runs a script by its path (ax_spawn.py); 40: test_method holds a script test's module (a test-tree file run as a program, no framework: graph_sql.script_tests); 39: a chained route link's `calls` row and `registration` label sit on the link's own line, with its own verb and path; 38: reg_key_fact drops a decoration string with a space in it (a description, not a key); 37: via_base / via_site, a caller that reaches a declaration through a base it is override-equivalent to (#1542), and injected_bean, the bean an injection point was wired to (#1384); 36: handoff_at, route_arg, callable_const, init_wrapper, init_alias, returns_fn — a const holding a wrapped handler registered at a route; 35: 0.1.5's 33 (#1598, the defines edges the path export links) joined 0.1.6's 33, two different fact sets under one number; 33 (0.1.6): calls carries the tier "stub" for a call inside a mock's stub or verification, reg_verb / lit_verb join a route by its HTTP method, and a handler's route joins its type's prefix; 32: cert_tier's why is worded per tier (an event_dispatch row says it sends the request or event), and the route facts #1633 changed (#1510), which merged without a bump; 31: event_dispatch edges (a published event reaches its listeners, #1391) and the pytest fixture_injection reading (#1631) change impact's facts; 30: registers, a bean another class's annotation registers (#1396, #1414); 29: the edges it links from the path export changed (#1402), and a cache written before it must not survive; 28: reexport_from, the file an `export *` line re-exports; 27: framework, the engine's framework_edge (#1509); 24: the test* naming convention requires a test class as owner (#1181); 23: owner/member disambiguated by file, two classes of one name no longer merging (#1188); 22: lex_parent, the innermost declaration enclosing each one by span (#1183); 21: cert_tier, the tier -> certainty table the call rules join on (#1131); 20: faccess, the engine's resolved field accesses (#1071); 3: decl_file facts (the import-time test-file rule); 14: the registration-key # layer; 15: the registration facts (two 14s landed independently, which is exactly the collision this # guards); 16: regsite folded into ax_registration's reg_key_fact; 20: implements_pair (#1011); 17/18: the tagged-template test registrar # (it.each`…`) and its table span @@ -1385,6 +1387,16 @@ class Impact: if ROUTEISH.fullmatch(r['value']): c = self.at(r['file'], r['line']) if c: lits.append((c, r['value'], r['file'], r['line'])) + # A HANDLER TABLE'S KEY is written as whatever string the dispatcher reads: usually dotted (`doc.created`), and + # usually through a constant (`TOPICS.CREATED`), so neither shape above carries it. Its writes come from + # ax_registration, which also leaves out the table's own key positions and the constant's declaration. + tregs = ax_registration.table_registrations(g.q, g.site_file) + tkeys = {r[4] for r in tregs} + if tkeys: + lits = [x for x in lits if x[1] not in tkeys] + for v, f, l in ax_registration.table_key_writes(g.q, tkeys): + c = self.at(f, l) + if c: lits.append((c, v, f, l)) W('literal', sorted(set(lits))) # a string written inside a decoration (@Listener(topics = "topicOne"), @RequestMapping("/a/{b}")) is in no # other table: literals does not carry it, and it is how a topic, a queue, a route or a bean qualifier binds @@ -1408,9 +1420,12 @@ class Impact: regk = [] # `rd` and not `decl`: `decl` is the dec_literal list above for rd, _f, _l, kind, key, _why in (ax_registration.decoration_keys(g.q, g.site_file) + ax_registration.value_route_registrations(g.q, g.site_file) - + ax_registration.registrations(g.q, g.site_file)): + + ax_registration.registrations(g.q, g.site_file) + tregs): if rd in g.sym and key: regk.append((rd, kind, key)) W('reg_key_fact', sorted(set(regk))) + # the callables in test files: a test that publishes a handler table's key drives the handler, and is not + # counted against the key the way a production writer is (dl/impact.dl, table_key) + W('test_code', sorted((i,) for i, s in g.sym.items() if s['is_test'] and s.get('method_id'))) # the HTTP method each side names, where it names one (ax_registration.route_verbs / literal_verbs) W('reg_verb', sorted(x for x in ax_registration.route_verbs(g.q, g.site_file) if x[0] in g.sym)) W('lit_verb', sorted(ax_registration.literal_verbs(g.q, self.at, g.site_file))) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path index 98ac6f2a..b44dbe53 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path @@ -1306,7 +1306,8 @@ def framework_note(g, ids, writers=()): if ff == f and a <= l <= b and (best is None or (b - a) < best[0]): best = (b - a, i) return best[1] if best else None hit = [] - for v, f, l in g.q("SELECT value, file, line FROM literals WHERE line > 0 AND value IS NOT NULL"): + # the literals, and a handler table's key where it is written through a constant (`publish(TOPICS.CREATED)`) + for v, f, l in ax_registration.key_writes(g.q, {r[4] for r in every if r[3] == 'table'}): if not isinstance(v, str): continue for key in ax_registration.route_candidates(v, registered): if key not in keys: continue diff --git a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl index 57d46319..1c15fb72 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl +++ b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl @@ -903,8 +903,18 @@ reg_key_fan(k, n) :- reg_key(_, _, k), n = count : { reg_key(_, _, k) }. // decoration argument is not always a registration (`@ValueSource(strings = {"p"})` is test DATA). A key written // everywhere identifies nothing either, whichever side the fan is on, and this needs no catalogue of which // decorations register and which do not. +// A HANDLER TABLE'S KEY (kind "table": `{ [TOPICS.CREATED]: onCreated }`, ax_registration.table_registrations) is a +// message type. Its writers are the services that publish it AND the tests that publish it to drive the consumer, and a +// suite that exercises one event type from ten tests has not made the type identify nothing: only the PRODUCTION +// writers count against the cap. And a handler registered under the key that writes it again (to log it, to pass it +// on) consumes that type; it is not a producer of it. +// test_code(c) c is declared in a test file +.decl test_code(c:symbol) .input test_code +.decl table_key(k:symbol) +table_key(k) :- reg_key(_, "table", k). .decl key_written(c:symbol, k:symbol) -key_written(c, k) :- literal(c, k, _, _). +key_written(c, k) :- literal(c, k, _, _), !table_key(k). +key_written(c, k) :- literal(c, k, _, _), table_key(k), !test_code(c), !reg_key(c, "table", k). .decl key_use_fan(k:symbol, n:number) key_use_fan(k, n) :- key_written(_, k), n = count : { key_written(_, k) }. .decl key_capped(k:symbol) @@ -921,7 +931,7 @@ key_capped(k) :- key_use_fan(k, n), key_use_cap(c), n > c. .decl reg_verb(b:symbol, k:symbol, v:symbol) .input reg_verb .decl lit_verb(a:symbol, k:symbol, v:symbol) .input lit_verb .decl key_join(a:symbol, b:symbol, k:symbol, w:symbol) -key_join(a, b, k, k) :- literal(a, k, _, _), reg_key(b, _, k), a != b. +key_join(a, b, k, k) :- literal(a, k, _, _), reg_key(b, _, k), a != b, !reg_key(a, "table", k). // the same, where the two spellings of the path differ: /orders/o-1/price written, /orders/{order_id}/price registered key_join(a, b, r, w) :- literal(a, w, _, _), route_alias(w, r), reg_key(b, _, r), a != b. .decl verb_fits(a:symbol, b:symbol, k:symbol, w:symbol) @@ -933,11 +943,17 @@ reg_capped(k) :- reg_key_fan(k, n), key_cap(c), n > c. // the writers of the key as registered (an alias spelling is not counted, as before). A helper relation, not // `count : { verb_fits(_, b, k, k) }`: Soufflé 2.5 counts an aggregate atom with a repeated variable as 1. .decl exact_fit(a:symbol, b:symbol, k:symbol) -exact_fit(a, b, k) :- verb_fits(a, b, k, k). +exact_fit(a, b, k) :- verb_fits(a, b, k, k), !table_key(k). +exact_fit(a, b, k) :- verb_fits(a, b, k, k), table_key(k), !test_code(a). .decl use_fan_for(b:symbol, k:symbol, n:number) use_fan_for(b, k, n) :- reg_key(b, _, k), n = count : { exact_fit(_, b, k) }. .decl fw_edge(a:symbol, b:symbol, how:symbol) fw_edge(a, b, "by key") :- verb_fits(a, b, k, _), !reg_capped(k), use_fan_for(b, k, n), key_use_cap(c), n <= c. +// and the other direction for a HANDLER TABLE, as for `remote`: what a publisher writes is what the handler registered +// under its type receives, so a change to the publisher's payload is a change to the handler's input. A direct row +// only, never a seed: whoever else reaches the handler does not thereby depend on this publisher. +direct(q, c, "uses", cat("handles what this publishes: registered in a handler table under \"", cat(k, "\" — whatever dispatches the table by that key calls it, no call does")), "by key", "", 0) + :- target(q, "method", m, _), fw_edge(m, c, "by key"), verb_fits(m, c, k, _), table_key(k), c != m. // a decorator that REBINDS THE NAME: `@audited def summarise` leaves `summarise` denoting the wrapper, so every // caller written with that name runs the wrapper. This one is the engine's own resolution, not a name match. fw_edge(a, b, "decorator") :- calls(a, m, _, _, _), decorated_name(m, b), a != b. diff --git a/skills/axiomcode/SKILL.md b/skills/axiomcode/SKILL.md index 9ded5b67..9456d8e1 100644 --- a/skills/axiomcode/SKILL.md +++ b/skills/axiomcode/SKILL.md @@ -71,7 +71,7 @@ An answer's label is the **worst** rung on its route. Read it before acting on t | `[defines]` · `[protocol]` · `[decorator by name]` | closure from its definer · interpreter-called method · wrapper rebinding the name | | `[fixture]` · `[at import]` | injected before the test body · module raised on import, test never collected | | `[spawns]` | the test runs the script as a child process, joined through the **path** it names — not an edge | -| `[by key]` | joined through a registration **string** (route, signal, CLI command) — not an edge | +| `[by key]` | joined through a registration **string** (route, signal, CLI command, the event type a handler table is keyed by) — not an edge | | `[stubs it]` | a call written inside a mock's stub or verification (`when(m.f())`, `verify(m).f()`, `Setup(x => x.F())`, `Received().F()`): names it, runs none of it — never a test route, listed apart | | `[in scope]` · `[by name]` · `[text]` | same name in the owner's scope · same name elsewhere (may be another thing) · text only | | `[alongside]` | declared in the same type or file — no call, no reference; its own section (`alongside` in `--json`), never a dependent | diff --git a/tests/cases/javascript/event-type-handler-table/case.json b/tests/cases/javascript/event-type-handler-table/case.json new file mode 100644 index 00000000..16591715 --- /dev/null +++ b/tests/cases/javascript/event-type-handler-table/case.json @@ -0,0 +1,24 @@ +{"lang": "javascript", "src": ".", + "checks": [ + {"why": "a publisher writes an event type through a constant (TOPICS.CREATED) and another service's handler table is keyed by the same constant, or by the same string as a method shorthand: impact of the publisher lists each handler registered under that type [by key], and not the handler registered under another type or an arrow inside a table of data", + "run": ["impact", "DocService.create", "--grep"], + "want": ["src/search/handlers.js:5", "src/audit/audit.js:5", "[by key]"], + "avoid": ["src/search/handlers.js:6", "src/audit/audit.js:10"]}, + {"why": "the tests that publish the type (through the service) reach the handler registered under it; a test that publishes another type does not", + "run": ["impact", "src/search/handlers.js:5", "--tests-only"], + "want": ["test/service.test.js", "by key"], + "avoid": ["test/drop.test.js"]}, + {"why": "the other handler's tests are the ones that publish ITS type, written through the same constant table", + "run": ["impact", "src/search/handlers.js:6", "--tests-only"], + "want": ["test/drop.test.js"], + "avoid": ["test/service.test.js"]}, + {"why": "a type written as a dotted string literal joins a handler NAMED as a table entry's value; path says a framework connects them instead of calling the two independent", + "run": ["path", "DocService.archive", "onArchived"], + "want": ["registered in a handler table under \"doc.archived\"", "NOT independent"], + "expect_error": true}, + {"why": "control: a publisher of another type stays independent of that handler", + "run": ["path", "DocService.create", "onArchived"], + "want": ["independent in this graph"], + "avoid": ["NOT independent"], + "expect_error": true} + ]} diff --git a/tests/cases/javascript/event-type-handler-table/src/audit/audit.js b/tests/cases/javascript/event-type-handler-table/src/audit/audit.js new file mode 100644 index 00000000..acc9d5a9 --- /dev/null +++ b/tests/cases/javascript/event-type-handler-table/src/audit/audit.js @@ -0,0 +1,11 @@ +function record(env) { return [env.type, env.payload]; } +function onArchived(env) { return record(env); } + +export const auditHandlers = { + 'doc.created'(env) { return record(env); }, + 'doc.archived': onArchived, +}; + +export const LABELS = { + 'doc.created': ['created', (env) => record(env)], +}; diff --git a/tests/cases/javascript/event-type-handler-table/src/pkg/topics.js b/tests/cases/javascript/event-type-handler-table/src/pkg/topics.js new file mode 100644 index 00000000..41f3cdab --- /dev/null +++ b/tests/cases/javascript/event-type-handler-table/src/pkg/topics.js @@ -0,0 +1,4 @@ +export const TOPICS = Object.freeze({ + CREATED: 'doc.created', + DELETED: 'doc.deleted', +}); diff --git a/tests/cases/javascript/event-type-handler-table/src/producer/service.js b/tests/cases/javascript/event-type-handler-table/src/producer/service.js new file mode 100644 index 00000000..a7f7cd40 --- /dev/null +++ b/tests/cases/javascript/event-type-handler-table/src/producer/service.js @@ -0,0 +1,14 @@ +import { TOPICS } from '../pkg/topics.js'; + +export class DocService { + constructor(bus) { this.bus = bus; } + + create(doc) { + this.bus.publish(TOPICS.CREATED, doc); + return doc; + } + + archive(doc) { + this.bus.publish('doc.archived', doc); + } +} diff --git a/tests/cases/javascript/event-type-handler-table/src/search/consumer.js b/tests/cases/javascript/event-type-handler-table/src/search/consumer.js new file mode 100644 index 00000000..3d999bd5 --- /dev/null +++ b/tests/cases/javascript/event-type-handler-table/src/search/consumer.js @@ -0,0 +1,4 @@ +export async function consume(handlers, msg) { + const handler = handlers[msg.headers.type]; + if (handler) await handler(msg); +} diff --git a/tests/cases/javascript/event-type-handler-table/src/search/handlers.js b/tests/cases/javascript/event-type-handler-table/src/search/handlers.js new file mode 100644 index 00000000..3bb08d29 --- /dev/null +++ b/tests/cases/javascript/event-type-handler-table/src/search/handlers.js @@ -0,0 +1,7 @@ +import { TOPICS } from '../pkg/topics.js'; +import { index, drop } from './indexer.js'; + +export const handlers = { + [TOPICS.CREATED]: async (env) => index(env), + [TOPICS.DELETED]: async (env) => drop(env), +}; diff --git a/tests/cases/javascript/event-type-handler-table/src/search/indexer.js b/tests/cases/javascript/event-type-handler-table/src/search/indexer.js new file mode 100644 index 00000000..d33b5d43 --- /dev/null +++ b/tests/cases/javascript/event-type-handler-table/src/search/indexer.js @@ -0,0 +1,2 @@ +export function index(env) { return env.payload; } +export function drop(env) { return env.payload.id; } diff --git a/tests/cases/javascript/event-type-handler-table/test/drop.test.js b/tests/cases/javascript/event-type-handler-table/test/drop.test.js new file mode 100644 index 00000000..b728e234 --- /dev/null +++ b/tests/cases/javascript/event-type-handler-table/test/drop.test.js @@ -0,0 +1,7 @@ +import { test } from 'node:test'; +import { TOPICS } from '../src/pkg/topics.js'; + +test('a delete is published by its type', () => { + const bus = { publish() {} }; + bus.publish(TOPICS.DELETED, { id: 'd1' }); +}); diff --git a/tests/cases/javascript/event-type-handler-table/test/service.test.js b/tests/cases/javascript/event-type-handler-table/test/service.test.js new file mode 100644 index 00000000..67b37d53 --- /dev/null +++ b/tests/cases/javascript/event-type-handler-table/test/service.test.js @@ -0,0 +1,7 @@ +import { test } from 'node:test'; +import { DocService } from '../src/producer/service.js'; + +test('create publishes the created event', () => { + const sent = []; + new DocService({ publish: (type, doc) => sent.push([type, doc]) }).create({ id: 'd1' }); +}); diff --git a/tests/cases/python/event-type-handler-table/app/__init__.py b/tests/cases/python/event-type-handler-table/app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cases/python/event-type-handler-table/app/handlers.py b/tests/cases/python/event-type-handler-table/app/handlers.py new file mode 100644 index 00000000..5d93dba9 --- /dev/null +++ b/tests/cases/python/event-type-handler-table/app/handlers.py @@ -0,0 +1,21 @@ +from app.topics import Topics + + +def on_created(env): + return env["payload"] + + +def on_deleted(env): + return env["payload"]["id"] + + +HANDLERS = { + Topics.CREATED: on_created, + "doc.deleted": on_deleted, +} + + +def consume(msg): + handler = HANDLERS.get(msg["headers"]["type"]) + if handler: + handler(msg) diff --git a/tests/cases/python/event-type-handler-table/app/service.py b/tests/cases/python/event-type-handler-table/app/service.py new file mode 100644 index 00000000..2a15d92c --- /dev/null +++ b/tests/cases/python/event-type-handler-table/app/service.py @@ -0,0 +1,6 @@ +from app.topics import Topics + + +def create(bus, doc): + bus.publish(Topics.CREATED, doc) + return doc diff --git a/tests/cases/python/event-type-handler-table/app/topics.py b/tests/cases/python/event-type-handler-table/app/topics.py new file mode 100644 index 00000000..aa19fb58 --- /dev/null +++ b/tests/cases/python/event-type-handler-table/app/topics.py @@ -0,0 +1,3 @@ +class Topics: + CREATED = "doc.created" + DELETED = "doc.deleted" diff --git a/tests/cases/python/event-type-handler-table/case.json b/tests/cases/python/event-type-handler-table/case.json new file mode 100644 index 00000000..338e20cd --- /dev/null +++ b/tests/cases/python/event-type-handler-table/case.json @@ -0,0 +1,11 @@ +{"lang": "python", "src": ".", + "checks": [ + {"why": "a dict of type -> handler, keyed by a class constant or by the string itself: impact of the publisher lists the handler registered under the type it publishes [by key], and not the handler of another type", + "run": ["impact", "create", "--grep"], + "want": ["app/handlers.py:4", "[by key]"], + "avoid": ["app/handlers.py:8"]}, + {"why": "the test that publishes the type through the service reaches the handler; a test that writes another type does not", + "run": ["impact", "on_created", "--tests-only"], + "want": ["tests/test_service.py", "by key"], + "avoid": ["tests/test_other.py"]} + ]} diff --git a/tests/cases/python/event-type-handler-table/tests/__init__.py b/tests/cases/python/event-type-handler-table/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cases/python/event-type-handler-table/tests/test_other.py b/tests/cases/python/event-type-handler-table/tests/test_other.py new file mode 100644 index 00000000..a1eb8187 --- /dev/null +++ b/tests/cases/python/event-type-handler-table/tests/test_other.py @@ -0,0 +1,4 @@ +def test_delete_is_published_by_its_type(): + sent = [] + sent.append(("doc.deleted", {"id": "d1"})) + assert sent diff --git a/tests/cases/python/event-type-handler-table/tests/test_service.py b/tests/cases/python/event-type-handler-table/tests/test_service.py new file mode 100644 index 00000000..451d7a49 --- /dev/null +++ b/tests/cases/python/event-type-handler-table/tests/test_service.py @@ -0,0 +1,10 @@ +from app.service import create + + +class Bus: + def publish(self, topic, doc): + pass + + +def test_create_publishes(): + create(Bus(), {"id": "d1"})