From 3d60d3ec90c0b79a94c72ddd4d34ee4822e32e14 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:01:32 -0700 Subject: [PATCH 01/11] impact: a test whose registrar call opens on an earlier line is a test A formatter puts a long test name on a line of its own: `it(` or `test.each(rows)(` opens the call, the name follows, and the arrow starts a later line that names no registrar. The rule that names an anonymous callable a test reads only its own declaration line, so the runner's test was test code but no test: a file whose tests are all written this way was never selected, and the change it covers listed it only as "a route this count does not credit". The callable is now a test when its line starts with the function value and the innermost multi-line call opened above it names a registrar on its first line. Innermost, so a callback on its own line handed to `settle(` inside a test body stays that call's argument (control). The same helper serves the impact facts and the query layer's test sets. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../skills/axiomcode/scripts/axiomcode-impact | 18 ++- .../skills/axiomcode/scripts/graph_sql.py | 41 +++++- .../test-registrar-modifiers/case.json | 118 ++++++++++++++++++ .../src/batch.test.ts | 6 + .../test-registrar-modifiers/src/batch.ts | 15 +++ .../test-registrar-modifiers/src/pricing.ts | 35 ++++++ .../src/settle.test.ts | 10 ++ .../src/table.test.ts | 24 ++++ .../src/wrapped.test.ts | 17 +++ 9 files changed, 277 insertions(+), 7 deletions(-) create mode 100644 tests/cases/typescript/test-registrar-modifiers/case.json create mode 100644 tests/cases/typescript/test-registrar-modifiers/src/batch.test.ts create mode 100644 tests/cases/typescript/test-registrar-modifiers/src/batch.ts create mode 100644 tests/cases/typescript/test-registrar-modifiers/src/pricing.ts create mode 100644 tests/cases/typescript/test-registrar-modifiers/src/settle.test.ts create mode 100644 tests/cases/typescript/test-registrar-modifiers/src/table.test.ts create mode 100644 tests/cases/typescript/test-registrar-modifiers/src/wrapped.test.ts diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index 4d515792..38e4a46a 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -1343,7 +1343,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 = '66' # 66: imports_fact names a module from its package root (a src layout) and has each test file import the conftest.py files above it; 65: guard_only also reads a conjunctive guard (`cond and __name__ == "__main__"`); 64: guard_only, a module body's call made only under `if __name__ == "__main__":`, which the import walk does not take; 63: imports_fact resolves an import written with its extension and a workspace package's specifier, and test_method takes no JavaScript / TypeScript helper by its name; 62: state_gate, state_gate_alloc, state_call_alloc, state_call_open, state_world, the callbacks one instance was given and the allocation each caller's receiver may be (JavaScript instance-state.dl); 61: filter_run, a servlet filter a test's context holds (added to HttpSecurity in a configuration the test loads, or a component) and the test methods that send a request through it; 57: a TypeScript object literal key is a ref of entity kind OBJECT_PROPERTY_KEY, kept past a bound access on its line; 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 + IMPACT_VERSION = '67' # 67: test_method takes an anonymous test whose registrar call opens on an earlier line (graph_sql.continued_registrar_arg); 66: imports_fact names a module from its package root (a src layout) and has each test file import the conftest.py files above it; 65: guard_only also reads a conjunctive guard (`cond and __name__ == "__main__"`); 64: guard_only, a module body's call made only under `if __name__ == "__main__":`, which the import walk does not take; 63: imports_fact resolves an import written with its extension and a workspace package's specifier, and test_method takes no JavaScript / TypeScript helper by its name; 62: state_gate, state_gate_alloc, state_call_alloc, state_call_open, state_world, the callbacks one instance was given and the allocation each caller's receiver may be (JavaScript instance-state.dl); 61: filter_run, a servlet filter a test's context holds (added to HttpSecurity in a configuration the test loads, or a component) and the test methods that send a request through it; 57: a TypeScript object literal key is a ref of entity kind OBJECT_PROPERTY_KEY, kept past a bound access on its line; 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 @@ -1806,12 +1806,22 @@ class Impact: f = g.site_file(r['file_path']); L = self.lines(f) if r['start_line'] - 1 < len(L) and EACH.search(L[r['start_line'] - 1]): tables.append((f, r['start_line'], r['end_line'] or r['start_line'])) - for i, sy in g.sym.items(): - if not (sy['is_test'] and sy.get('method_id') and (sy['name'] or '').startswith('<')): continue - if not sy.get('file') or not sy.get('line'): continue + # a formatter puts a long test name on a line of its own: `it(` / `test.each(rows)(` opens the call, the name + # follows, and the arrow starts a later line naming no registrar — so the call's span, not the arrow's line, + # says whose argument it is (graph_sql.continued_registrar_arg: the innermost call opened above it) + anon = [(i, sy) for i, sy in g.sym.items() if sy['is_test'] and sy.get('method_id') and (sy['name'] or '').startswith('<') + and sy.get('file') and sy.get('line')] + tfiles = {sy['file'] for _, sy in anon} + calls = {} + for r in (g.q("SELECT file_path, start_line, end_line FROM call_sites WHERE start_line > 0 AND end_line > start_line") if g.has('call_sites') else []): + f = g.site_file(r['file_path']) + if f in tfiles: calls.setdefault(f, []).append((r['start_line'], r['end_line'])) + spans = _gs.registrar_spans([(f, a, b) for f, s in calls.items() for a, b in s], self.lines) + for i, sy in anon: L = self.lines(sy['file']) if sy['line'] - 1 < len(L) and REGISTRAR.search(L[sy['line'] - 1]): tm.append(i) elif any(f == sy['file'] and a <= sy['line'] <= b for f, a, b in tables): tm.append(i) + elif _gs.continued_registrar_arg(sy['file'], sy['line'], self.lines, spans, calls): tm.append(i) # …and a SCRIPT TEST, a test-tree file run as a program with no framework: its module is the test (graph_sql, # the one rule the hooks' fast path reads too) import graph_sql diff --git a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py index 34521239..06c9ba01 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py @@ -794,6 +794,32 @@ def test_decoration(name, derived=()): TEST_REGISTRAR = re.compile(r'\b(it|test|bench)\s*(\.\w+)*\s*(\.\w+)?\s*[(<`]') EACH_TABLE = re.compile(r'\b(it|test|bench|describe)\s*\.\s*each\b') +# a line that STARTS with a function value: the callable is an argument continued from a call opened above it +ARG_LINE_CALLABLE = re.compile(r'^\s*(?:async\s+)?(?:function\b|\(|[A-Za-z_$][\w$]*\s*=>)') + + +def registrar_spans(rows, lines): + """{file: [(start, end)]} of the multi-line calls whose first line names a test registrar — `it(` / `test.each(rows)(` + with the name and the body on lines of their own, the shape a formatter gives a long test name. `rows` are + (file, start_line, end_line) of call sites, the file already relative.""" + out = {} + for f, a, b in rows: + if not f or not a or not b or b <= a: continue + L = lines(f) + if a - 1 < len(L) and TEST_REGISTRAR.search(L[a - 1]): out.setdefault(f, []).append((a, b)) + return out + + +def continued_registrar_arg(f, ln, lines, spans, calls): + """whether the anonymous callable declared at `f`:`ln` is an argument of a test registrar call opened on an EARLIER + line: its own line starts with the function value, and the innermost call spanning that line, among those opened + above it, is a registrar call. Innermost, so a callback handed to `waitFor(` inside a test body stays a callback. + `calls` is {file: [(start, end)]} of every multi-line call site, `spans` the registrar subset.""" + if f not in spans: return False + L = lines(f) + if not (ln - 1 < len(L) and ARG_LINE_CALLABLE.match(L[ln - 1])): return False + inner = min(((a, b) for a, b in calls.get(f, ()) if a < ln <= b), key=lambda s: s[1] - s[0], default=None) + return inner is not None and inner in spans[f] # A SCRIPT TEST: a file under the test tree that calls no test framework and is run as a program — `test/run.js` @@ -885,12 +911,21 @@ def _test_sets(q, lines=None, rel=None): if _has(q, 'call_sites') else []): f = rel(fp) if rel else fp; L = lines(f) if a - 1 < len(L) and EACH_TABLE.search(L[a - 1]): tables.append((f, a, b or a)) - for sid, name, f, ln in q("""SELECT id, name, file, line FROM symbols - WHERE is_test=1 AND method_id IS NOT NULL AND file IS NOT NULL AND line > 0"""): - if not (name or '').startswith('<'): continue + anon = [r for r in q("""SELECT id, name, file, line FROM symbols + WHERE is_test=1 AND method_id IS NOT NULL AND file IS NOT NULL AND line > 0""") + if (r[1] or '').startswith('<')] + calls = {} + tfiles = {r[2] for r in anon} + for fp, a, b in (q("""SELECT file_path, start_line, end_line FROM call_sites + WHERE start_line > 0 AND end_line > start_line""") if _has(q, 'call_sites') else []): + f = rel(fp) if rel else fp + if f in tfiles: calls.setdefault(f, []).append((a, b)) + spans = registrar_spans([(f, a, b) for f, s in calls.items() for a, b in s], lines) + for sid, name, f, ln in anon: L = lines(f) if ln - 1 < len(L) and TEST_REGISTRAR.search(L[ln - 1]): tm.add(sid) elif any(tf == f and a <= ln <= b for tf, a, b in tables): tm.add(sid) + elif continued_registrar_arg(f, ln, lines, spans, calls): tm.add(sid) rows = q("""SELECT id, kind, file FROM symbols WHERE is_test=1 AND method_id IS NOT NULL AND file IS NOT NULL""") st = script_tests([tuple(r) for r in rows], lambda f: '\n'.join(lines(f)), tm) tm |= st; fx -= st diff --git a/tests/cases/typescript/test-registrar-modifiers/case.json b/tests/cases/typescript/test-registrar-modifiers/case.json new file mode 100644 index 00000000..38a1e6f5 --- /dev/null +++ b/tests/cases/typescript/test-registrar-modifiers/case.json @@ -0,0 +1,118 @@ +{ + "lang": "typescript", + "src": "src", + "checks": [ + { + "why": "a formatter puts a long test name on its own line: `test.each(rows)(` opens the call and the arrow starts a later line naming no registrar, yet the runner runs it, so what it calls is tested by the file", + "run": [ + "impact", + "total", + "--tests-only" + ], + "want": [ + "src/wrapped.test.ts (" + ], + "avoid": [] + }, + { + "why": "the same layout for a plain `it(`: the name on its own line, the body on the next", + "run": [ + "impact", + "packaging", + "--tests-only" + ], + "want": [ + "src/wrapped.test.ts (" + ], + "avoid": [ + "reach it by a route this count does not credit" + ] + }, + { + "why": "control: a callback on its own line handed to an ordinary call inside a test body is that call's argument, not a second test", + "run": [ + "impact", + "late", + "--tests-only" + ], + "want": [ + "of 8 test method(s)", + "src/settle.test.ts (" + ], + "avoid": [ + "of 9 test method(s)" + ] + }, + { + "why": "test.each(rows) returns the registrar and the body is handed to THAT call: the body is a test, so what it calls is tested by the file", + "run": [ + "impact", + "total", + "--tests-only" + ], + "want": [ + "src/table.test.ts (" + ], + "avoid": [ + "reach it by a route this count does not credit" + ] + }, + { + "why": "describe.each(rows)(name, body) runs its suite body, so the tests registered inside it are tests", + "run": [ + "impact", + "discount", + "--tests-only" + ], + "want": [ + "src/table.test.ts (" + ], + "avoid": [ + "reach it by a route this count does not credit" + ] + }, + { + "why": "a modifier written as a member (it.concurrent) is still the runner registering a test", + "run": [ + "impact", + "shipping", + "--tests-only" + ], + "want": [ + "src/table.test.ts (" + ], + "avoid": [ + "reach it by a route this count does not credit" + ] + }, + { + "why": "a chain of modifiers ending in a table (test.concurrent.each(rows)) registers a test too", + "run": [ + "impact", + "rounding", + "--tests-only" + ], + "want": [ + "src/table.test.ts (" + ], + "avoid": [ + "reach it by a route this count does not credit" + ] + }, + { + "why": "control: `each` called on an object that is not a test runner is no test registrar; the audit is reached only through the test that calls runAudit", + "run": [ + "impact", + "audit", + "--tests-only" + ], + "want": [ + "src/batch.test.ts (" + ], + "avoid": [ + "src/table.test.ts", + "batch.ts::" + ] + } + ] +} diff --git a/tests/cases/typescript/test-registrar-modifiers/src/batch.test.ts b/tests/cases/typescript/test-registrar-modifiers/src/batch.test.ts new file mode 100644 index 00000000..fdc247d6 --- /dev/null +++ b/tests/cases/typescript/test-registrar-modifiers/src/batch.test.ts @@ -0,0 +1,6 @@ +import { expect, it } from 'vitest' +import { runAudit } from './batch' + +it('runs the audit', () => { + expect(runAudit()).toBeUndefined() +}) diff --git a/tests/cases/typescript/test-registrar-modifiers/src/batch.ts b/tests/cases/typescript/test-registrar-modifiers/src/batch.ts new file mode 100644 index 00000000..a47b04c8 --- /dev/null +++ b/tests/cases/typescript/test-registrar-modifiers/src/batch.ts @@ -0,0 +1,15 @@ +import { audit } from './pricing' + +// control: `each` on an object that is not a test runner hands over a plain callback; +// the function it runs is not a test +const rows = { + each(values: number[]) { + return (label: string, fn: (v: number) => void) => values.forEach(fn) + }, +} + +export function runAudit(): void { + rows.each([1, 2])('audit', (v) => { + audit(v) + }) +} diff --git a/tests/cases/typescript/test-registrar-modifiers/src/pricing.ts b/tests/cases/typescript/test-registrar-modifiers/src/pricing.ts new file mode 100644 index 00000000..8a766227 --- /dev/null +++ b/tests/cases/typescript/test-registrar-modifiers/src/pricing.ts @@ -0,0 +1,35 @@ +export function total(items: number[]): number { + return items.reduce((a, b) => a + b, 0) +} + +export function discount(amount: number): number { + return amount > 100 ? amount * 0.9 : amount +} + +export function shipping(weight: number): number { + return weight * 2 +} + +export function rounding(value: number): number { + return Math.round(value * 100) / 100 +} + +export function tax(amount: number): number { + return amount * 0.2 +} + +export function audit(amount: number): number { + return amount +} + +export function settle(fn: () => number): number { + return fn() +} + +export function late(): number { + return 1 +} + +export function packaging(count: number): number { + return count * 3 +} diff --git a/tests/cases/typescript/test-registrar-modifiers/src/settle.test.ts b/tests/cases/typescript/test-registrar-modifiers/src/settle.test.ts new file mode 100644 index 00000000..7c26a667 --- /dev/null +++ b/tests/cases/typescript/test-registrar-modifiers/src/settle.test.ts @@ -0,0 +1,10 @@ +import { expect, it } from 'vitest' +import { late, settle } from './pricing' + +// control: inside a test body, a callback on its own line is the argument of `settle(`, not of `it(` +it('settles late', () => { + const got = settle( + () => late(), + ) + expect(got).toBe(1) +}) diff --git a/tests/cases/typescript/test-registrar-modifiers/src/table.test.ts b/tests/cases/typescript/test-registrar-modifiers/src/table.test.ts new file mode 100644 index 00000000..11bb1a29 --- /dev/null +++ b/tests/cases/typescript/test-registrar-modifiers/src/table.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it, test } from 'vitest' +import { discount, rounding, shipping, total } from './pricing' + +// a table-driven test: test.each(rows) returns the registrar, which is then called +test.each([[[1, 2], 3], [[4], 4]])('total of %o', (items, want) => { + expect(total(items as number[])).toBe(want) +}) + +// a table-driven suite: the suite body registers ordinary tests +describe.each([150, 50])('discount of %d', (amount) => { + it('is never more than the amount', () => { + expect(discount(amount)).toBeLessThanOrEqual(amount) + }) +}) + +// a modifier written as a member: the runner still runs it +it.concurrent('ships by weight', () => { + expect(shipping(2)).toBe(4) +}) + +// a modifier chain ending in a table +test.concurrent.each([1.005, 2.5])('rounds %d', (v) => { + expect(rounding(v)).toBeGreaterThan(0) +}) diff --git a/tests/cases/typescript/test-registrar-modifiers/src/wrapped.test.ts b/tests/cases/typescript/test-registrar-modifiers/src/wrapped.test.ts new file mode 100644 index 00000000..7f85ee89 --- /dev/null +++ b/tests/cases/typescript/test-registrar-modifiers/src/wrapped.test.ts @@ -0,0 +1,17 @@ +import { expect, it, test } from 'vitest' +import { packaging, total } from './pricing' + +// a formatter's layout for a long name: the call opens on one line, the name and the body follow on their own +test.each([[[1, 2], 3]])( + 'adds every item of a long table row, whatever its order: %o', + async (items, want) => { + expect(total(items as number[])).toBe(want) + }, +) + +it( + 'charges for packaging per item, rounding nothing on the way through the order', + () => { + expect(packaging(2)).toBe(6) + }, +) From 0ad69360d122a115682856c86e06d40c40a1f1bd Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:01:40 -0700 Subject: [PATCH 02/11] impact: a vitest / jest set-up file runs before the tests it configures A runner configuration names files it loads before every test file of the project (setupFiles, jest's setupFilesAfterEnv) and files whose exported setup it calls once before the run (globalSetup). Nothing imports or calls them, so a function they reach -- a client a set-up file configures at module level, a code generator a global set-up runs -- reached no test, while breaking it fails every test of the run. ax_runner_setup reads the string entries of those keys in vitest/vite/jest configuration files (relative, `/`, `__dirname + '/...'`, jest's rootDir), skipping a package's own set-up. The set-up file's module, and for a global set-up the function it exports to the runner (`export const setup = codegen`, `export default fn`, a declared setup/teardown), is a fixture (runs_before) of each test under the configuration's root; a global set-up's failure aborts the whole run, so its root is the nearest directory above that lists projects. Control: a helper file no configuration names reaches no test. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../axiomcode/scripts/ax_runner_setup.py | 85 +++++++++++++++++++ .../skills/axiomcode/scripts/axiomcode-impact | 35 +++++++- .../typescript/runner-setup-files/case.json | 48 +++++++++++ .../runner-setup-files/src/app/lib/client.ts | 15 ++++ .../src/app/lib/generate.ts | 4 + .../src/app/test/helpers.ts | 3 + .../src/app/test/math.test.ts | 5 ++ .../src/app/test/schedule.test.ts | 8 ++ .../src/app/test/scripts/codegen.ts | 5 ++ .../src/app/test/scripts/globalSetup.ts | 3 + .../runner-setup-files/src/app/test/setup.ts | 3 + .../src/app/vitest.config.ts | 8 ++ .../src/other/plain.test.ts | 5 ++ .../src/other/vitest.config.ts | 3 + 14 files changed, 228 insertions(+), 2 deletions(-) create mode 100644 plugins/axiomcode/skills/axiomcode/scripts/ax_runner_setup.py create mode 100644 tests/cases/typescript/runner-setup-files/case.json create mode 100644 tests/cases/typescript/runner-setup-files/src/app/lib/client.ts create mode 100644 tests/cases/typescript/runner-setup-files/src/app/lib/generate.ts create mode 100644 tests/cases/typescript/runner-setup-files/src/app/test/helpers.ts create mode 100644 tests/cases/typescript/runner-setup-files/src/app/test/math.test.ts create mode 100644 tests/cases/typescript/runner-setup-files/src/app/test/schedule.test.ts create mode 100644 tests/cases/typescript/runner-setup-files/src/app/test/scripts/codegen.ts create mode 100644 tests/cases/typescript/runner-setup-files/src/app/test/scripts/globalSetup.ts create mode 100644 tests/cases/typescript/runner-setup-files/src/app/test/setup.ts create mode 100644 tests/cases/typescript/runner-setup-files/src/app/vitest.config.ts create mode 100644 tests/cases/typescript/runner-setup-files/src/other/plain.test.ts create mode 100644 tests/cases/typescript/runner-setup-files/src/other/vitest.config.ts diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_runner_setup.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_runner_setup.py new file mode 100644 index 00000000..67c74191 --- /dev/null +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_runner_setup.py @@ -0,0 +1,85 @@ +"""The set-up files a JavaScript / TypeScript test runner runs before the tests it collects. + +A vitest or jest configuration names files the runner loads before every test file of the project +(`setupFiles`, jest's `setupFilesAfterEnv`) and files whose exported `setup` / `teardown` (or default export) it calls +once, before anything is collected (`globalSetup`). No test file imports them and nothing calls them, so a function they +reach — a client the set-up file configures at module level, a code generator the global set-up runs — reached no test +at all, while breaking it fails every test of the run. + +What is read, and nothing else: string literals in the value of those keys in a runner configuration file +(vitest.config.*, vite.config.*, vitest.workspace.*, vitest.shared.*, jest.config.*), resolved against the configuration's +directory (`/` and `__dirname + '/…'` included; jest's `rootDir: '..'` is honoured). A package specifier (a +library's set-up such as `@testing-library/jest-dom/vitest`) is not this repository's code and is skipped. + +Which tests each file runs before: + - setupFiles / setupFilesAfterEnv: the test files under the configuration's root (the project it configures); + - globalSetup: every test file of the RUN — a failing global set-up aborts the whole run, every project of a + workspace with it — so the root is the nearest directory above holding a configuration that lists projects + (`projects:` / `workspace`, or a vitest.workspace file), else the configuration's own root. +""" +import os +import re + +CONFIG_NAME = re.compile(r'^(vitest\.config|vite\.config|vitest\.workspace|vitest\.shared|jest\.config)(\.[\w-]+)*\.(c|m)?[jt]s$') +KEY = re.compile(r'\b(setupFiles|setupFilesAfterEnv|globalSetup)\s*:\s*(\[[^\]]*\]|[^,\n}]+)') +STRING = re.compile(r'''(['"`])((?:(?!\1).)*)\1''') +ROOTDIR = re.compile(r'''\brootDir\s*:\s*(['"])([^'"]*)\1''') +LISTS_PROJECTS = re.compile(r'\b(projects|workspace)\s*:\s*\[') +EXTS = ('', '.ts', '.tsx', '.mts', '.cts', '.js', '.mjs', '.cjs', '/index.ts', '/index.js') +SKIP_DIRS = {'node_modules', '.git', 'dist', 'build', 'coverage', '.axiomcode', '.next', 'out'} + + +def _configs(repo): + for d, dirs, files in os.walk(repo): + dirs[:] = [x for x in dirs if x not in SKIP_DIRS and not x.startswith('.')] + for f in files: + if CONFIG_NAME.match(f): + yield os.path.relpath(os.path.join(d, f), repo) + + +def _read(repo, rel): + try: + with open(os.path.join(repo, rel), encoding='utf-8', errors='replace') as h: return h.read() + except OSError: + return '' + + +def _resolve(repo, base, spec, files): + """the repository file a set-up entry names, or None (a package, or nothing there)""" + spec = spec.replace('', '.') + if not spec.startswith(('.', '/')): return None # a package's own set-up + rel = os.path.normpath(os.path.join(base, spec.lstrip('/') if spec.startswith('/') else spec)) + for e in EXTS: + if (rel + e).replace(os.sep, '/') in files: return (rel + e).replace(os.sep, '/') + return None + + +def setup_files(repo, files): + """[(setup file, kind, scope dir)] for every set-up file a runner configuration here names; `files` is the set of + repository-relative files the graph holds. kind is 'each' (setupFiles*) or 'global' (globalSetup).""" + cfgs = sorted(_configs(repo)) + lister = {os.path.dirname(c) for c in cfgs if os.path.basename(c).startswith('vitest.workspace') + or LISTS_PROJECTS.search(_read(repo, c))} + out = set() + for c in cfgs: + text = _read(repo, c) + if not KEY.search(text): continue + cdir = os.path.dirname(c) + rd = ROOTDIR.search(text) + root = os.path.normpath(os.path.join(cdir, rd.group(2))) if rd else cdir + root = '' if root == '.' else root + for key, val in KEY.findall(text): + kind = 'global' if key == 'globalSetup' else 'each' + for _q, spec in STRING.findall(val): + f = _resolve(repo, root if spec.startswith('') else cdir, spec, files) + if f is None: continue + scope = root + if kind == 'global': + up = [d for d in lister if d == '' or scope == d or scope.startswith(d + '/')] + if up: scope = min(up, key=len) + out.add((f, kind, scope)) + return sorted(out) + + +def in_scope(file, scope): + return bool(file) and (scope == '' or file == scope or file.startswith(scope + '/')) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index 38e4a46a..ed959136 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -978,6 +978,37 @@ class Impact: if not (tn == ns or tn.startswith(ns + '.')): continue out.add((m, fx)) return out + # a vitest / jest SET-UP FILE runs before the tests of its project (setupFiles), or before the whole run (globalSetup), + # and nothing imports or calls it (ax_runner_setup). Its module, and for a global set-up the function the runner + # calls (`export const setup = codegen`, `export default fn`, a declared setup / teardown), is a fixture of each. + GLOBAL_EXPORT = re.compile(r'^\s*export\s+(?:(?:const|let|var)\s+(setup|teardown)\s*=\s*([A-Za-z_$][\w$]*)\s*;?\s*$' + r'|default\s+([A-Za-z_$][\w$]*)\s*;?\s*$)', re.M) + IMPORT_FROM = re.compile(r'''^\s*import\s+[^'"]*?\bfrom\s*['"](\.[^'"]+)['"]''', re.M) + def runner_setups(self, tests): + import ax_runner_setup + g = self.g + js = ('.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs') + tfiles = {m: g.sym.get(m, {}).get('file') for m in tests} + if not any((f or '').endswith(js) for f in tfiles.values()): return set() + by_file = collections.defaultdict(list) + for i, s in g.sym.items(): + if s.get('method_id') and s.get('file'): by_file[s['file']].append(i) + out = set() + for f, kind, scope in ax_runner_setup.setup_files(g.repo, set(by_file)): + fx = [i for i in by_file[f] if g.sym[i]['kind'] == 'module'] + if kind == 'global': + fx += [i for i in by_file[f] if g.sym[i]['name'] in ('setup', 'teardown', 'default')] + text = '\n'.join(self.lines(f)) # the raw text: code() blanks the specifiers + names = {a or b for _k, a, b in self.GLOBAL_EXPORT.findall(text)} - {''} + if names: + near = {os.path.normpath(os.path.join(os.path.dirname(f), s)) for s in self.IMPORT_FROM.findall(text)} + for nf, ids in by_file.items(): + if os.path.splitext(nf)[0] in near or nf == f: + fx += [i for i in ids if g.sym[i]['name'] in names] + for m, tf in tfiles.items(): + if tf and tf != f and ax_runner_setup.in_scope(tf, scope): + out.update((m, x) for x in fx) + return out def cs_namespace(self, i): """the namespace a C# member is declared in: its qualified name less the owner's and its own""" sy = self.g.sym[i] @@ -1343,7 +1374,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 = '67' # 67: test_method takes an anonymous test whose registrar call opens on an earlier line (graph_sql.continued_registrar_arg); 66: imports_fact names a module from its package root (a src layout) and has each test file import the conftest.py files above it; 65: guard_only also reads a conjunctive guard (`cond and __name__ == "__main__"`); 64: guard_only, a module body's call made only under `if __name__ == "__main__":`, which the import walk does not take; 63: imports_fact resolves an import written with its extension and a workspace package's specifier, and test_method takes no JavaScript / TypeScript helper by its name; 62: state_gate, state_gate_alloc, state_call_alloc, state_call_open, state_world, the callbacks one instance was given and the allocation each caller's receiver may be (JavaScript instance-state.dl); 61: filter_run, a servlet filter a test's context holds (added to HttpSecurity in a configuration the test loads, or a component) and the test methods that send a request through it; 57: a TypeScript object literal key is a ref of entity kind OBJECT_PROPERTY_KEY, kept past a bound access on its line; 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 + IMPACT_VERSION = '68' # 68: runs_before carries a vitest / jest set-up file's module (and a global set-up's exported setup) for the tests it runs before (ax_runner_setup); 67: test_method takes an anonymous test whose registrar call opens on an earlier line (graph_sql.continued_registrar_arg); 66: imports_fact names a module from its package root (a src layout) and has each test file import the conftest.py files above it; 65: guard_only also reads a conjunctive guard (`cond and __name__ == "__main__"`); 64: guard_only, a module body's call made only under `if __name__ == "__main__":`, which the import walk does not take; 63: imports_fact resolves an import written with its extension and a workspace package's specifier, and test_method takes no JavaScript / TypeScript helper by its name; 62: state_gate, state_gate_alloc, state_call_alloc, state_call_open, state_world, the callbacks one instance was given and the allocation each caller's receiver may be (JavaScript instance-state.dl); 61: filter_run, a servlet filter a test's context holds (added to HttpSecurity in a configuration the test loads, or a component) and the test methods that send a request through it; 57: a TypeScript object literal key is a ref of entity kind OBJECT_PROPERTY_KEY, kept past a bound access on its line; 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 @@ -1830,7 +1861,7 @@ class Impact: tm += sorted(scripts) fx = [i for i, s in g.sym.items() if s['is_test'] and i not in scripts and ((s.get('type_id') and not s.get('method_id')) or s['kind'] in ('constructor', 'module') or _gs.is_fixture_callable(s['name'], decs.get(i, ())))] W('test_method', [(m,) for m in tm]); W('fixture', [(m,) for m in fx]) - W('runs_before', sorted(self.wide_fixtures(tm, decs))) + W('runs_before', sorted(self.wide_fixtures(tm, decs) | self.runner_setups(tm))) self.cs_test_links(g, set(tm), W) # ── which fixture runs before which test, when nothing calls it ──────────────────────────────────────── # pytest hands a fixture to a test BY THE NAME OF A PARAMETER, and the fixture that serves a whole directory diff --git a/tests/cases/typescript/runner-setup-files/case.json b/tests/cases/typescript/runner-setup-files/case.json new file mode 100644 index 00000000..40011c84 --- /dev/null +++ b/tests/cases/typescript/runner-setup-files/case.json @@ -0,0 +1,48 @@ +{ + "lang": "typescript", + "src": "src", + "checks": [ + { + "why": "a setupFiles entry runs before every test file of its project and nothing imports it: what its module calls is reached by each of those tests, even one that never names it", + "run": [ + "impact", + "setNotifyFunction", + "--tests-only" + ], + "want": [ + "src/app/test/math.test.ts (", + "src/app/test/schedule.test.ts (" + ], + "avoid": [ + "src/other/plain.test.ts" + ] + }, + { + "why": "a globalSetup file's exported setup is called by the runner once before the run: the generator it holds reaches every test of the run", + "run": [ + "impact", + "renderSchema", + "--tests-only" + ], + "want": [ + "src/app/test/math.test.ts (" + ], + "avoid": [ + "src/other/plain.test.ts" + ] + }, + { + "why": "control: a helper file that no configuration names is no set-up file, so what it calls reaches no test", + "run": [ + "impact", + "unusedHelper", + "--tests-only" + ], + "want": [], + "avoid": [ + "src/app/test/math.test.ts (", + "src/app/test/schedule.test.ts (" + ] + } + ] +} diff --git a/tests/cases/typescript/runner-setup-files/src/app/lib/client.ts b/tests/cases/typescript/runner-setup-files/src/app/lib/client.ts new file mode 100644 index 00000000..ec2adcba --- /dev/null +++ b/tests/cases/typescript/runner-setup-files/src/app/lib/client.ts @@ -0,0 +1,15 @@ +let notify: (fn: () => void) => void = (fn) => fn() + +// a set-up file calls this at module level, before any test of the project runs +export function setNotifyFunction(fn: (cb: () => void) => void): void { + notify = fn +} + +export function schedule(cb: () => void): void { + notify(cb) +} + +// control: called only by a helper no configuration names and no test imports +export function unusedHelper(): number { + return 1 +} diff --git a/tests/cases/typescript/runner-setup-files/src/app/lib/generate.ts b/tests/cases/typescript/runner-setup-files/src/app/lib/generate.ts new file mode 100644 index 00000000..176506c1 --- /dev/null +++ b/tests/cases/typescript/runner-setup-files/src/app/lib/generate.ts @@ -0,0 +1,4 @@ +// what the global set-up's code generator runs, once, before the whole run +export function renderSchema(name: string): string { + return `schema ${name}` +} diff --git a/tests/cases/typescript/runner-setup-files/src/app/test/helpers.ts b/tests/cases/typescript/runner-setup-files/src/app/test/helpers.ts new file mode 100644 index 00000000..5228b513 --- /dev/null +++ b/tests/cases/typescript/runner-setup-files/src/app/test/helpers.ts @@ -0,0 +1,3 @@ +import { unusedHelper } from '../lib/client' + +export const value = unusedHelper() diff --git a/tests/cases/typescript/runner-setup-files/src/app/test/math.test.ts b/tests/cases/typescript/runner-setup-files/src/app/test/math.test.ts new file mode 100644 index 00000000..e7ad3e8f --- /dev/null +++ b/tests/cases/typescript/runner-setup-files/src/app/test/math.test.ts @@ -0,0 +1,5 @@ +import { expect, it } from 'vitest' + +it('adds', () => { + expect(1 + 1).toBe(2) +}) diff --git a/tests/cases/typescript/runner-setup-files/src/app/test/schedule.test.ts b/tests/cases/typescript/runner-setup-files/src/app/test/schedule.test.ts new file mode 100644 index 00000000..d092a4a7 --- /dev/null +++ b/tests/cases/typescript/runner-setup-files/src/app/test/schedule.test.ts @@ -0,0 +1,8 @@ +import { expect, it } from 'vitest' +import { schedule } from '../lib/client' + +it('schedules', () => { + let ran = false + schedule(() => { ran = true }) + expect(ran).toBe(true) +}) diff --git a/tests/cases/typescript/runner-setup-files/src/app/test/scripts/codegen.ts b/tests/cases/typescript/runner-setup-files/src/app/test/scripts/codegen.ts new file mode 100644 index 00000000..8f263622 --- /dev/null +++ b/tests/cases/typescript/runner-setup-files/src/app/test/scripts/codegen.ts @@ -0,0 +1,5 @@ +import { renderSchema } from '../../lib/generate' + +export async function codegen(): Promise { + renderSchema('api') +} diff --git a/tests/cases/typescript/runner-setup-files/src/app/test/scripts/globalSetup.ts b/tests/cases/typescript/runner-setup-files/src/app/test/scripts/globalSetup.ts new file mode 100644 index 00000000..ce8d03fc --- /dev/null +++ b/tests/cases/typescript/runner-setup-files/src/app/test/scripts/globalSetup.ts @@ -0,0 +1,3 @@ +import { codegen } from './codegen' + +export const setup = codegen diff --git a/tests/cases/typescript/runner-setup-files/src/app/test/setup.ts b/tests/cases/typescript/runner-setup-files/src/app/test/setup.ts new file mode 100644 index 00000000..2100a894 --- /dev/null +++ b/tests/cases/typescript/runner-setup-files/src/app/test/setup.ts @@ -0,0 +1,3 @@ +import { setNotifyFunction } from '../lib/client' + +setNotifyFunction((cb) => cb()) diff --git a/tests/cases/typescript/runner-setup-files/src/app/vitest.config.ts b/tests/cases/typescript/runner-setup-files/src/app/vitest.config.ts new file mode 100644 index 00000000..5a4ec2cd --- /dev/null +++ b/tests/cases/typescript/runner-setup-files/src/app/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + setupFiles: ['@testing-library/jest-dom/vitest', './test/setup.ts'], + globalSetup: ['./test/scripts/globalSetup.ts'], + }, +}) diff --git a/tests/cases/typescript/runner-setup-files/src/other/plain.test.ts b/tests/cases/typescript/runner-setup-files/src/other/plain.test.ts new file mode 100644 index 00000000..9c381044 --- /dev/null +++ b/tests/cases/typescript/runner-setup-files/src/other/plain.test.ts @@ -0,0 +1,5 @@ +import { expect, it } from 'vitest' + +it('is plain', () => { + expect(true).toBe(true) +}) diff --git a/tests/cases/typescript/runner-setup-files/src/other/vitest.config.ts b/tests/cases/typescript/runner-setup-files/src/other/vitest.config.ts new file mode 100644 index 00000000..aee034e1 --- /dev/null +++ b/tests/cases/typescript/runner-setup-files/src/other/vitest.config.ts @@ -0,0 +1,3 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ test: {} }) From 25296c2880abae872b0bf1e7df29c588f656ca6b Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 9 Oct 2026 21:39:26 -0700 Subject: [PATCH 03/11] typescript: Object.assign(fn, members) evaluates to fn, so a call of the function object runs it A callable API is often a function object merged with its members and typed by a callable interface whose call signature has no body: `export const widget: Factory = assign(createWidget, members)`, `Object.assign(task, { started, finished })`. The holder held the merge's result, for which nothing returned a function, so a call of the merged object ran nothing and everything below the function had no caller from the package's own tests or from the packages that import it. value_branch now follows `Object.assign(target, ...sources)` to its first argument, written out or through an alias whose value is `Object.assign` (`export const assign = Object.assign`), never by the bare name. Control: a function passed as a later source is not what a call of the result runs. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../engine/resolution/value-flow.dl | 16 ++++++ graph/typescript/souffle/decls_all.dl | 2 + .../object-assign-function-object/case.json | 54 +++++++++++++++++++ .../src/external.test.ts | 6 +++ .../src/factory.test.ts | 10 ++++ .../src/factory.ts | 42 +++++++++++++++ .../src/utils.ts | 2 + 7 files changed, 132 insertions(+) create mode 100644 tests/cases/typescript/object-assign-function-object/case.json create mode 100644 tests/cases/typescript/object-assign-function-object/src/external.test.ts create mode 100644 tests/cases/typescript/object-assign-function-object/src/factory.test.ts create mode 100644 tests/cases/typescript/object-assign-function-object/src/factory.ts create mode 100644 tests/cases/typescript/object-assign-function-object/src/utils.ts diff --git a/graph/typescript/engine/resolution/value-flow.dl b/graph/typescript/engine/resolution/value-flow.dl index 284c6e88..acc74483 100644 --- a/graph/typescript/engine/resolution/value-flow.dl +++ b/graph/typescript/engine/resolution/value-flow.dl @@ -431,6 +431,22 @@ value_branch(root, x) :- value_branch(root, e), expr_kind_is_transparent(k), expr_child("client", e, r, _, x), edge_role_is_operand(r). +// `Object.assign(target, …sources)` evaluates to its TARGET. A callable API is often a function object built this way +// (`export const widget: Factory = assign(createWidget, members)`, `Object.assign(task, { started, finished })`) +// and typed by a callable interface whose call signature has no body: the holder held the call's result, which the +// platform's merge never returned a function for, so a call of it ran nothing. The merge is read the same way when +// it is reached through an alias (`export const assign = Object.assign`), never by the name `assign` alone. +value_branch(root, x) :- value_branch(root, e), + object_assign_call(e), + expr_child("client", e, "ARGUMENT", "0", x). +object_assign_ref(x) :- property_access_recv(x, "assign", q), + expr_name("client", "Object", q), + expr_kind("client", "IDENTIFIER_REFERENCE", _, q). +object_assign_call(e) :- call_callee_expr(_, e, ne), object_assign_ref(ne). +object_assign_call(e) :- call_callee_expr(_, e, ne), + expr_holder(ne, h), + holder_value(h, v), + object_assign_ref(v). binary_op_yields_operand("??", "LEFT_OPERAND"). binary_op_yields_operand("??", "RIGHT_OPERAND"). binary_op_yields_operand("||", "LEFT_OPERAND"). diff --git a/graph/typescript/souffle/decls_all.dl b/graph/typescript/souffle/decls_all.dl index 500217c8..c8e368d1 100644 --- a/graph/typescript/souffle/decls_all.dl +++ b/graph/typescript/souffle/decls_all.dl @@ -188,6 +188,8 @@ .decl field_has_declared_type(c0:symbol) .decl field_in_enclosing(c0:symbol,c1:symbol,c2:symbol) .decl holder_value(c0:symbol,c1:symbol) +.decl object_assign_ref(c0:symbol) +.decl object_assign_call(c0:symbol) // #1208: nested element access, static callable fields, expression callees, object literals typed by context .decl ref_via_alias(c0:symbol,c1:symbol) .decl ref_elem_ref(c0:symbol,c1:symbol) diff --git a/tests/cases/typescript/object-assign-function-object/case.json b/tests/cases/typescript/object-assign-function-object/case.json new file mode 100644 index 00000000..1eb20f60 --- /dev/null +++ b/tests/cases/typescript/object-assign-function-object/case.json @@ -0,0 +1,54 @@ +{ + "lang": "typescript", + "src": "src", + "checks": [ + { + "why": "Object.assign(fn, members) evaluates to fn: a call of the merged function object, imported and typed by a callable interface, runs fn, here through a re-exported alias of Object.assign", + "run": [ + "impact", + "track", + "--tests-only" + ], + "want": [ + "src/factory.test.ts (" + ], + "avoid": [] + }, + { + "why": "the same with Object.assign written out and an inline function as the target", + "run": [ + "impact", + "trackDirect", + "--tests-only" + ], + "want": [ + "src/factory.test.ts (" + ], + "avoid": [] + }, + { + "why": "control: Object.assign evaluates to its first argument only; a function passed as a later source is not what a call of the result runs", + "run": [ + "impact", + "sourceOnly", + "--tests-only" + ], + "want": [], + "avoid": [ + "src/external.test.ts (" + ] + }, + { + "why": "…while the target it was merged into is", + "run": [ + "impact", + "target", + "--tests-only" + ], + "want": [ + "src/external.test.ts (" + ], + "avoid": [] + } + ] +} diff --git a/tests/cases/typescript/object-assign-function-object/src/external.test.ts b/tests/cases/typescript/object-assign-function-object/src/external.test.ts new file mode 100644 index 00000000..0443cbc7 --- /dev/null +++ b/tests/cases/typescript/object-assign-function-object/src/external.test.ts @@ -0,0 +1,6 @@ +import { expect, it } from 'vitest' +import { merged } from './factory' + +it('calls the merged target', () => { + expect(merged(1)).toBe(1) +}) diff --git a/tests/cases/typescript/object-assign-function-object/src/factory.test.ts b/tests/cases/typescript/object-assign-function-object/src/factory.test.ts new file mode 100644 index 00000000..bd5be17a --- /dev/null +++ b/tests/cases/typescript/object-assign-function-object/src/factory.test.ts @@ -0,0 +1,10 @@ +import { expect, it } from 'vitest' +import { direct, factory } from './factory' + +it('builds through the factory', () => { + expect(factory(1)).toBe(2) +}) + +it('builds through the direct merge', () => { + expect(direct(1)).toBe(2) +}) diff --git a/tests/cases/typescript/object-assign-function-object/src/factory.ts b/tests/cases/typescript/object-assign-function-object/src/factory.ts new file mode 100644 index 00000000..84b07202 --- /dev/null +++ b/tests/cases/typescript/object-assign-function-object/src/factory.ts @@ -0,0 +1,42 @@ +import { assign } from './utils' + +// A callable API built as a FUNCTION OBJECT: the function is merged with its members by Object.assign, +// and the result is typed by a callable interface whose call signature has no body. +export interface Factory { + (v: number): number + box(v: number): number +} + +export function track(v: number): number { + return v + 1 +} + +function create(v: number): number { + return track(v) +} + +const members = { + box(v: number): number { + return v + }, +} + +export const factory: Factory = assign(create, members) + +export function trackDirect(v: number): number { + return v * 2 +} + +// written with Object.assign itself and an inline function +export const direct = Object.assign((v: number) => trackDirect(v), { kind: 'direct' }) + +// control: the merge evaluates to its TARGET; a function passed as a later argument is not what a call of it runs +export function sourceOnly(v: number): number { + return v +} + +function target(v: number): number { + return v +} + +export const merged = Object.assign(target, { extra: sourceOnly }, sourceOnly) diff --git a/tests/cases/typescript/object-assign-function-object/src/utils.ts b/tests/cases/typescript/object-assign-function-object/src/utils.ts new file mode 100644 index 00000000..90ee85d3 --- /dev/null +++ b/tests/cases/typescript/object-assign-function-object/src/utils.ts @@ -0,0 +1,2 @@ +// the platform's merge, re-exported under a short name +export const assign = Object.assign From 26b20b47e9d01d15e96390e2fcd255dbb8f3edf8 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 9 Oct 2026 21:09:17 -0700 Subject: [PATCH 04/11] query: a call that selected an overload signature reaches the overload set's implementation The compiler binds `pick(1)` to `function pick(x: number): number`, a declaration with no body, and call_edges records exactly that. The implementation below the signatures -- the one body that runs -- had no caller in any verb: an edit to it reached no test (`tests`, `impact `: "the change is local"), anything it calls was cut off from the tests above it, and `path` called the test and the implementation independent. The engine already answers which body runs (ext_call_runs_edge, from call_runs_method). graph_sql.overload_impl_edges pairs it with the call site that selected a same-named signature declared above the implementation (same file, same owner), and every reader takes the pair: impact's calls facts (a verified edge, at the site's line), the fast path's edge list, and path, as an `overload` hop. Controls: a same-named function in another module is no part of the set, and a placeholder name (``, shared by every anonymous arrow of a module) names no set at all -- without that guard member-assigned-function-names cited a caller at another arrow's call. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../skills/axiomcode/scripts/ax_edges.py | 4 +- .../skills/axiomcode/scripts/axiomcode-impact | 14 +++- .../skills/axiomcode/scripts/axiomcode-path | 8 +- .../skills/axiomcode/scripts/graph_sql.py | 24 ++++++ .../overload-implementation/case.json | 76 +++++++++++++++++++ .../overload-implementation/src/lib.test.ts | 10 +++ .../overload-implementation/src/lib.ts | 19 +++++ .../overload-implementation/src/other.ts | 4 + 8 files changed, 152 insertions(+), 7 deletions(-) create mode 100644 tests/cases/typescript/overload-implementation/case.json create mode 100644 tests/cases/typescript/overload-implementation/src/lib.test.ts create mode 100644 tests/cases/typescript/overload-implementation/src/lib.ts create mode 100644 tests/cases/typescript/overload-implementation/src/other.ts diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py index 5ecdf17e..3e16f21c 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py @@ -45,6 +45,7 @@ 'runtime_observed': 1, # seen in a runtime trace; real, but no call site stands behind it 'multi_inferred': 1, # several declarations fit; each one is a real candidate 'dispatch': 2, # a base method to an override that is actually instantiated + 'overload': 0, # the overload signature a call selected runs its set's implementation: one body, certain 'callback_registered': 3, # handed over as a value and invoked by whoever holds it 'event_dispatch': 3, # emitted here, handled there 'remote': 5, # a request crosses a process to its handler (remote_edge): no call site names it. @@ -67,6 +68,7 @@ 'known_edge': 'resolved to one declaration', 'multi_inferred': 'several declarations fit; each is a real candidate', 'dispatch': 'a base method to an override the project instantiates', + 'overload': 'the call selected an overload signature; this is the implementation that runs', 'callback_registered': 'handed over as a value and invoked by whoever holds it', 'event_dispatch': 'emitted here, handled there', 'remote': 'NOT a call site: a request crosses a process to the handler that serves it (transport and destination on the hop)', @@ -169,7 +171,7 @@ def legend(tiers): # as a value, and a capped fan-out exists precisely BECAUSE the candidate set was too large to # enumerate, so what is in the graph is a sample of it. DIRECT_CERT = { - 'known_edge': 'resolved', 'boundary_lib': 'resolved', 'boundary_generated': 'resolved', + 'known_edge': 'resolved', 'boundary_lib': 'resolved', 'boundary_generated': 'resolved', 'overload': 'resolved', 'implicit_constructor': 'resolved', 'written': 'resolved', 'known_implicit_ctor': 'resolved', 'known_builtin_operator': 'resolved', 'runtime_observed': 'resolved', 'multi_inferred': 'one of a set', diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index ed959136..cf11bc46 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -1374,7 +1374,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 = '68' # 68: runs_before carries a vitest / jest set-up file's module (and a global set-up's exported setup) for the tests it runs before (ax_runner_setup); 67: test_method takes an anonymous test whose registrar call opens on an earlier line (graph_sql.continued_registrar_arg); 66: imports_fact names a module from its package root (a src layout) and has each test file import the conftest.py files above it; 65: guard_only also reads a conjunctive guard (`cond and __name__ == "__main__"`); 64: guard_only, a module body's call made only under `if __name__ == "__main__":`, which the import walk does not take; 63: imports_fact resolves an import written with its extension and a workspace package's specifier, and test_method takes no JavaScript / TypeScript helper by its name; 62: state_gate, state_gate_alloc, state_call_alloc, state_call_open, state_world, the callbacks one instance was given and the allocation each caller's receiver may be (JavaScript instance-state.dl); 61: filter_run, a servlet filter a test's context holds (added to HttpSecurity in a configuration the test loads, or a component) and the test methods that send a request through it; 57: a TypeScript object literal key is a ref of entity kind OBJECT_PROPERTY_KEY, kept past a bound access on its line; 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 + IMPACT_VERSION = '69' # 69: calls carries the edge from a call that selected an overload signature to the overload set's implementation (graph_sql.overload_impl_edges); 68: runs_before carries a vitest / jest set-up file's module (and a global set-up's exported setup) for the tests it runs before (ax_runner_setup); 67: test_method takes an anonymous test whose registrar call opens on an earlier line (graph_sql.continued_registrar_arg); 66: imports_fact names a module from its package root (a src layout) and has each test file import the conftest.py files above it; 65: guard_only also reads a conjunctive guard (`cond and __name__ == "__main__"`); 64: guard_only, a module body's call made only under `if __name__ == "__main__":`, which the import walk does not take; 63: imports_fact resolves an import written with its extension and a workspace package's specifier, and test_method takes no JavaScript / TypeScript helper by its name; 62: state_gate, state_gate_alloc, state_call_alloc, state_call_open, state_world, the callbacks one instance was given and the allocation each caller's receiver may be (JavaScript instance-state.dl); 61: filter_run, a servlet filter a test's context holds (added to HttpSecurity in a configuration the test loads, or a component) and the test methods that send a request through it; 57: a TypeScript object literal key is a ref of entity kind OBJECT_PROPERTY_KEY, kept past a bound access on its line; 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 @@ -1575,14 +1575,18 @@ class Impact: # on cert_tier, and a tier with no row would drop its dependents out of the answer entirely rather # than mislabel them, which is the worse of the two failures. ax_edges.direct_cert has the default. W('cert_tier', sorted({(t, ax_edges.direct_cert(t), ax_edges.direct_why(t)) - for t in {r[0] for r in g.q("SELECT DISTINCT tier FROM call_edges")} | {ax_edges.STUB_TIER}})) + for t in {r[0] for r in g.q("SELECT DISTINCT tier FROM call_edges")} | {ax_edges.STUB_TIER, 'overload'}})) # a call written inside a mock's stub or verification keeps its row and loses its tier: "stub" (ax_edges.stub_sites) stubs = ax_edges.stub_sites(lambda s, p: g.q(s, *p)) if g.has('call_sites') else set() # a chained route link's edge sits on the line of its own verb and path, where `registration` labels it link_line = ax_registration.route_site_lines(g.q, g.site_file) W('calls', [(r['caller_id'], r['callee_method_id'], ax_edges.STUB_TIER if r['call_site_id'] in stubs else r['tier'], g.site_file(r['file_path']) if r['file_path'] else '', link_line.get(r['call_site_id'], r['start_line'] or 0)) for r in g.q("SELECT e.call_site_id, e.caller_id, e.callee_method_id, e.tier, s.file_path, s.start_line FROM call_edges e LEFT JOIN call_sites s ON s.id = e.call_site_id WHERE e.callee_provenance = 'client' AND e.callee_method_id IS NOT NULL")] - + self.protected_name_stubs()) + + self.protected_name_stubs() + # a call that selected an overload signature runs the overload set's implementation (graph_sql) + + [(c, i, ax_edges.STUB_TIER if s in stubs else t, g.site_file(fp) if fp else '', ln or 0) + for s, c, i, t in _gs.overload_impl_edges(lambda sql, *p: [tuple(r) for r in g.q(sql, *p)]) + for fp, ln in ([tuple(r) for r in g.q("SELECT file_path, start_line FROM call_sites WHERE id = ?", s)] or [('', 0)])]) # A CALL UNDER `if __name__ == "__main__":` DOES NOT RUN ON IMPORT. The guard is true only when the file is run # as a script, so a module body's calls inside it are real edges (the script makes them) but no hop for the # import walk (dl/impact.dl up_running): otherwise a module ending in a demo block reads as breaking at import for @@ -2980,7 +2984,7 @@ def main(argv): # absorbed: a front end that gains a tier should show up here the first time anyone queries, not go # quietly into the bottom rung forever. UNKNOWN_TIERS = set() - CERT_UP = {'known_edge': 'sound', 'library': 'sound', 'written': 'sound', 'multi_inferred': 'one of a set', 'dispatch': 'dispatch', 'defines': 'defines', 'by key': 'by key', 'spawns': 'spawns', 'decorator': 'sound', 'decorator by name': 'decorator by name', 'at import': 'at import', 'at load': 'at load', 'protocol': 'protocol', + CERT_UP = {'known_edge': 'sound', 'library': 'sound', 'written': 'sound', 'overload': 'sound', 'multi_inferred': 'one of a set', 'dispatch': 'dispatch', 'defines': 'defines', 'by key': 'by key', 'spawns': 'spawns', 'decorator': 'sound', 'decorator by name': 'decorator by name', 'at import': 'at import', 'at load': 'at load', 'protocol': 'protocol', # handed over as a value and invoked by whoever holds it (`callback_registered`), or emitted # here and handled there (`event_dispatch`). Real engine edges, so `by name` understates them; # registrations rather than resolutions, so `one of a set` — which claims a resolved target @@ -3104,6 +3108,8 @@ def main(argv): real = {(r[0], r[1]) for r in g.q("SELECT caller_id, callee_method_id FROM call_edges WHERE callee_method_id IS NOT NULL")} # the engine's own edges, once if g.has('ext_ctor_implicit_type'): # `new T()` where T writes no constructor: the edge is the type (#1473) real |= {(r[0], r[1]) for r in g.q("SELECT s.caller_id, x.c1 FROM ext_ctor_implicit_type x JOIN call_sites s ON s.id = x.c0")} + # a call that selected an overload signature: the engine's own ext_call_runs_edge says it runs the implementation + real |= {(c, i) for _s, c, i, _t in _gs.overload_impl_edges(lambda sql, *p: [tuple(r) for r in g.q(sql, *p)])} callers_real = {a for a, _ in real} for ch in chains.values(): for a, b in zip(ch, ch[1:]): diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path index bb67ac13..d41405e8 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path @@ -989,11 +989,12 @@ class G: # ── facts: exported once per graph, reused while graph.sqlite is unchanged ─────────────────────────────── # EXPORT_VERSION is in the stamp beside the graph's mtime: bump it whenever the edges this writes change, or an # upgraded plugin keeps answering from the edges the old code exported (the graph is unchanged, so `index` says it - # is up to date and nothing rebuilds). 4: both 3s below, which landed on two branches under one number; + # is up to date and nothing rebuilds). 5: a call that selected an overload signature has an `overload` edge to the set's + # implementation (graph_sql.overload_impl_edges); 4: both 3s below, which landed on two branches under one number; # 3 (0.1.5): on one line a JavaScript anonymous callable nests only where its name does (#1598); # 3 (0.1.6): a call written inside a mock's stub or verification is not an edge (ax_edges.stub_sites); # 2: defines needs a written, strictly wider span (#1402, #1399); 1 was the bare mtime. - EXPORT_VERSION = '4' + EXPORT_VERSION = '5' def export(self): stamp = os.path.join(self.facts, 'stamp'); want = f"{self.db_mtime}:{self.EXPORT_VERSION}" def fresh(): @@ -1018,6 +1019,8 @@ class G: # calls into libraries: an edge to a terminal node (a library method has no out-edges here — nothing is inferred about its # body), so `path '*' java.nio.file.Files.readAllBytes` can ask who reaches that library call edges += [(r['caller_id'], r['callee_method_id'], 'library') for r in self.q("SELECT DISTINCT caller_id, callee_method_id FROM call_edges WHERE tier = 'boundary_lib' AND callee_method_id IS NOT NULL")] + # a call that selected an overload signature runs the set's implementation (graph_sql.overload_impl_edges) + edges += [(c, i, 'overload') for sid, c, i, _t in graph_sql.overload_impl_edges(lambda s_, *p_: [tuple(r) for r in self.q(s_, *p_)]) if sid not in stubs] # a callable defined inside another one's body: nobody calls it by name, but it runs only after its definer did. # One builder for this and the fast path (graph_sql._edges), so the two cannot disagree on what contains what. edges += ax_edges.defines_edges(((r['file'], r['line'], r['end_line'], r['id'], r.get('display'), r['method_id'], r.get('qualified_name')) @@ -1191,6 +1194,7 @@ def verify(g, chain, srcs, dst, adj): for a, b, t in hops: # a resolved hop must exist as an engine edge, not only as a fact we wrote if t in ('known_edge', 'multi_inferred') and not g.q("SELECT 1 FROM call_edges WHERE caller_id = ? AND callee_method_id = ? AND tier = ? LIMIT 1", a, b, t): bad.append((a, b, t)) if t == 'library' and not g.q("SELECT 1 FROM call_edges WHERE caller_id = ? AND callee_method_id = ? AND tier = 'boundary_lib' LIMIT 1", a, b): bad.append((a, b, t)) + if t == 'overload' and not any(c == a and i == b for _s, c, i, _t in graph_sql.overload_impl_edges(lambda s_, *p_: [tuple(r) for r in g.q(s_, *p_)])): bad.append((a, b, t)) if t == 'written' and not any(r['caller_id'] == a for r in getattr(g, 'SITES', {}).get(b, [])): bad.append((a, b, t)) if t in ax_edges.OUTSIDE_CALL and not outside_hop_holds(g, a, b, t): bad.append((a, b, t)) # plain BFS over the same facts: the second implementation must agree on the shortest length diff --git a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py index 06c9ba01..82f679e5 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py @@ -544,11 +544,35 @@ def _tests(cur, depth, q): # client call (its tier), a call into a library (terminal — nothing is inferred past it), `defines` (a callable # declared inside another, by line span), and `dispatch` (a candidate the engine narrowed a virtual call to). +def overload_impl_edges(q): + """[(call_site_id, caller_id, implementation_id, tier)]: a call that selected an OVERLOAD SIGNATURE runs the + implementation of that overload set. The compiler binds `pick(1)` to `function pick(x: number): number`, a declaration + with no body, and call_edges records exactly that — so the implementation, the one body that runs, had no caller at + all: an edit to it reached no test, `impact` called it local and `path` called the test and it independent. The + engine's own answer is ext_call_runs_edge (caller -> the implementation a call runs); this pairs it with the call + site that selected a same-named signature declared above the implementation (same file, same owner). A placeholder + name (``) names no overload set: two arrows of one file share it, and the engine's identity rows would pair + them.""" + if not q("SELECT 1 FROM sqlite_master WHERE name='ext_call_runs_edge'"): return [] + return [tuple(r) for r in q("""SELECT DISTINCT e.call_site_id, e.caller_id, r.c1, e.tier + FROM ext_call_runs_edge r + JOIN call_edges e ON e.caller_id = r.c0 + JOIN methods s ON s.id = e.callee_method_id + JOIN methods i ON i.id = r.c1 + WHERE r.c1 <> e.callee_method_id AND s.name = i.name AND s.file_path = i.file_path + AND COALESCE(s.owner_type_id, '') = COALESCE(i.owner_type_id, '') + AND s.name NOT LIKE '<%' AND s.end_line < i.start_line + AND s.provenance = 'client' AND i.provenance = 'client' + AND NOT EXISTS (SELECT 1 FROM call_edges x WHERE x.call_site_id = e.call_site_id + AND x.callee_method_id = r.c1)""")] + + def _edges(q): # a call inside a mock's stub or verification is not an edge: the same set the path export drops (ax_edges.stub_sites) stubs = ax_edges.stub_sites(lambda s, p: q(s, *p)) e = [(r[1], r[2], r[3]) for r in q("""SELECT call_site_id, caller_id, callee_method_id, tier FROM call_edges WHERE callee_method_id IS NOT NULL AND callee_provenance='client'""") if r[0] not in stubs] + e += [(c, i, t) for s, c, i, t in overload_impl_edges(q) if s not in stubs] e += [(r[0], r[1], 'library') for r in q("""SELECT DISTINCT caller_id, callee_method_id FROM call_edges WHERE tier='boundary_lib' AND callee_method_id IS NOT NULL""")] # defines: the innermost enclosing callable, from the line spans of the callables in each file diff --git a/tests/cases/typescript/overload-implementation/case.json b/tests/cases/typescript/overload-implementation/case.json new file mode 100644 index 00000000..d7123979 --- /dev/null +++ b/tests/cases/typescript/overload-implementation/case.json @@ -0,0 +1,76 @@ +{ + "lang": "typescript", + "src": "src", + "checks": [ + { + "why": "a call binds to an overload SIGNATURE, which has no body; the implementation is what runs, so a test calling the function reaches an edit to it", + "run": [ + "impact", + "src/lib.ts:5", + "--tests-only" + ], + "want": [ + "src/lib.test.ts (" + ], + "avoid": [ + "0 of", + "not found" + ] + }, + { + "why": "the same for an overloaded method", + "run": [ + "impact", + "src/lib.ts:16", + "--tests-only" + ], + "want": [ + "src/lib.test.ts (" + ], + "avoid": [ + "0 of" + ] + }, + { + "why": "and further down: what the implementation calls is reached through it", + "run": [ + "impact", + "normalize", + "--tests-only" + ], + "want": [ + "src/lib.test.ts (" + ], + "avoid": [ + "0 of" + ] + }, + { + "why": "path walks from the test into the implementation through the signature the call selected", + "run": [ + "path", + "src/lib.test.ts", + "src/lib.ts:5" + ], + "want": [ + "[overload] pick", + "verified: every printed hop" + ], + "avoid": [ + "the two are independent" + ] + }, + { + "why": "control: a same-named function in another module is not in that overload set", + "run": [ + "impact", + "src/other.ts:2", + "--tests-only" + ], + "want": [], + "avoid": [ + "src/lib.test.ts (" + ] + } + ] +} diff --git a/tests/cases/typescript/overload-implementation/src/lib.test.ts b/tests/cases/typescript/overload-implementation/src/lib.test.ts new file mode 100644 index 00000000..be2ade80 --- /dev/null +++ b/tests/cases/typescript/overload-implementation/src/lib.test.ts @@ -0,0 +1,10 @@ +import { expect, it } from 'vitest' +import { Box, pick } from './lib' + +it('picks', () => { + expect(pick(1)).toBe(1) +}) + +it('boxes', () => { + expect(new Box().get('a')).toBe('a') +}) diff --git a/tests/cases/typescript/overload-implementation/src/lib.ts b/tests/cases/typescript/overload-implementation/src/lib.ts new file mode 100644 index 00000000..77a26550 --- /dev/null +++ b/tests/cases/typescript/overload-implementation/src/lib.ts @@ -0,0 +1,19 @@ +// An overloaded function: the compiler binds every call to one of the bodiless signatures, and the implementation +// below them is the only body that runs. +export function pick(x: number): number +export function pick(x: string): string +export function pick(x: any): any { + return normalize(x) +} + +export function normalize(x: T): T { + return x +} + +export class Box { + get(k: string): string + get(k: number): number + get(k: any): any { + return k + } +} diff --git a/tests/cases/typescript/overload-implementation/src/other.ts b/tests/cases/typescript/overload-implementation/src/other.ts new file mode 100644 index 00000000..5ef981e1 --- /dev/null +++ b/tests/cases/typescript/overload-implementation/src/other.ts @@ -0,0 +1,4 @@ +// control: a function of the same name in another module, not part of that overload set +export function pick(x: boolean): boolean { + return x +} From c933dd317c6fd2708ff0aadcfb91122c4be6e14c Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:42:20 -0700 Subject: [PATCH 05/11] fix(impact): two static over-reach sources in Python transitive impact Measured against runtime traces on the Python oracle corpus, every false transitive-reach row has a static edge the runtime never made (the scorer accepts any runtime caller at any depth, so context-insensitivity cannot produce one). Two of the edge kinds are wrong by construction and are fixed here; reach precision 0.675 -> 0.724, reach recall -0.1pt (src) / -0.2pt (test-side), test-file recall -0.1pt, callers and path unchanged. 1. A decoration string naming a PARAMETER of the decorated declaration is no registration key (ax_registration.decoration_key_strings, shape 4), nor is a string inside another call in the decoration (shape 5). `@option("--params", "-p", "params") def main(params)`: the flags are what a caller writes to reach the command; the parameter name is written by every function that builds a dict with that key, and the by-key join made each of them a caller of the command. The flags stay keys. 2. The "decorator by name" hop lands on what the decorator RETURNS, not on the decorator's body. It used to run decorated -> decorator, making the decorated function (and everything reaching it: a route's tests) a caller of whatever the decorator calls. Calling the decorated name never runs the decorator; it runs what the decorator returned: - bare `@d`: d's return; written as a call `@d(...)`: the return of what d returned (decorated_call); - a wrapper (fn_returns, from the engine's method_returns_method): the decorated declaration now stands for that wrapper; - the function handed back unchanged (fn_returns_param, a registering decorator): no hop at all; the decoration itself runs at import, which the import walk already follows; - unknown return: the old hop stays (a route the walk cannot judge). IMPACT_VERSION 70 (new facts fn_returns, fn_returns_param, decorated_call). Cases: decorator-by-name-runs-its-wrapper, option-parameter-name-is-no-key (each fails on the base, with a control that must not change); decorator-the-project-declares now expects the route it really takes (`at import`) instead of the by-name hop. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../axiomcode/scripts/ax_registration.py | 28 +++++++++++++++-- .../skills/axiomcode/scripts/axiomcode-impact | 14 ++++++++- .../skills/axiomcode/scripts/dl/impact.dl | 30 ++++++++++++++++++- .../case.json | 26 ++++++++++++++++ .../handlers.py | 12 ++++++++ .../loader.py | 2 ++ .../tests/test_handlers.py | 5 ++++ .../tests/test_work.py | 5 ++++ .../tools.py | 25 ++++++++++++++++ .../work.py | 13 ++++++++ .../decorator-the-project-declares/case.json | 8 ++--- .../option-parameter-name-is-no-key/case.json | 16 ++++++++++ .../option-parameter-name-is-no-key/cli.py | 17 +++++++++++ .../option-parameter-name-is-no-key/config.py | 2 ++ .../tests/test_cli.py | 6 ++++ .../tests/test_config.py | 5 ++++ 16 files changed, 205 insertions(+), 9 deletions(-) create mode 100644 tests/cases/python/decorator-by-name-runs-its-wrapper/case.json create mode 100644 tests/cases/python/decorator-by-name-runs-its-wrapper/handlers.py create mode 100644 tests/cases/python/decorator-by-name-runs-its-wrapper/loader.py create mode 100644 tests/cases/python/decorator-by-name-runs-its-wrapper/tests/test_handlers.py create mode 100644 tests/cases/python/decorator-by-name-runs-its-wrapper/tests/test_work.py create mode 100644 tests/cases/python/decorator-by-name-runs-its-wrapper/tools.py create mode 100644 tests/cases/python/decorator-by-name-runs-its-wrapper/work.py create mode 100644 tests/cases/python/option-parameter-name-is-no-key/case.json create mode 100644 tests/cases/python/option-parameter-name-is-no-key/cli.py create mode 100644 tests/cases/python/option-parameter-name-is-no-key/config.py create mode 100644 tests/cases/python/option-parameter-name-is-no-key/tests/test_cli.py create mode 100644 tests/cases/python/option-parameter-name-is-no-key/tests/test_config.py diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py index 6eb56111..0167e2c4 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py @@ -671,12 +671,17 @@ def _string_literals(q): # 3. a string that names a MEMBER OF A TYPE THE SAME DECORATION NAMES: `@SelectProvider(type = StockSql.class, # method = "byShelf")` points at StockSql.byShelf; it is a reference to that method, not a key for this one. # Only decided with the graph (`names_member(type, name)`); without it the string is kept. -_STRING = re.compile(r'"([^"]{1,120})"|\'([^\']{1,120})\'') +# 4. a string that names a PARAMETER OF THE DECLARATION IT DECORATES: `@option("--params", "-p", "params")` on +# `def main(url, params)` binds the value a caller passes after `--params` to `params`. The flags are what a caller +# writes to reach the declaration; the parameter name is written by every function that builds a dict with a +# `params` key, and joined as a key it made each of them a caller of the command. +# 5. a string inside ANOTHER call in the decoration: `type=File("wb")` configures a value, it names nothing registered. +_STRING =re.compile(r'"([^"]{1,120})"|\'([^\']{1,120})\'') _KEYWORD_BEFORE = re.compile(r'(\w+)\s*[=:]\s*[\[{(]?\s*(?:(?:"[^"]*"|\'[^\']*\')\s*,\s*)*$') _TYPE_ARG = re.compile(r'(? 1: continue + if params and key in params: continue out.add(key) return sorted(out) +def _call_depth(t, i): + """how many parentheses are open at offset i of a decoration's text, strings blanked: 1 is the decoration's own + argument list""" + return _STRING.sub(lambda m: '"' + ' ' * (len(m.group(0)) - 2) + '"', t[:i]).count('(') - \ + _STRING.sub(lambda m: '"' + ' ' * (len(m.group(0)) - 2) + '"', t[:i]).count(')') + + +def _params_of(signature): + """the parameter names a `name(a, b=1, *c)` signature declares""" + m = re.search(r'\((.*)\)', signature or '') + if not m: return set() + return {re.sub(r'[:=].*$', '', p).strip().lstrip('*') for p in m.group(1).split(',')} - {''} + + def member_names(q): """names_member for decoration_key_strings, read from the graph: does a type of this simple name declare a member of that name""" @@ -720,13 +741,14 @@ def decoration_keys(q, site_file=None): # production code; nothing is lost by declining to read a test's own decoration as a registration. tests = {r[0] for r in q("SELECT id FROM symbols WHERE is_test = 1")} if _has(q, 'symbols') else set() members = member_names(q) + sigs = dict(q("SELECT id, signature FROM symbols WHERE signature IS NOT NULL")) if _has(q, 'symbols') else {} out = [] for owner, name, text, f, l in q("""SELECT owner_id, name, text, file, line FROM decorations WHERE text IS NOT NULL AND text <> '' AND owner_id IS NOT NULL"""): if owner in tests: continue short = (name or '').split('.')[-1] - for key in decoration_key_strings(text, name, members): + for key in decoration_key_strings(text, name, members, _params_of(sigs.get(owner))): kind = 'route' if key.startswith('/') else 'key' why = (f'registered as a route "{key}" by @{short} — the router calls it, no call site does' if kind == 'route' else f'registered under "{key}" by @{short} — whoever writes that string reaches it, and no call site does') diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index 9f3a4100..c1fb637b 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -1343,7 +1343,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 = '69' # 69: a library-callback site is a library receiver only where a hand-back from it is summarised (ax_edges.library_receiver_sites); 68: both 67s below, from two branches; 67: spawns_fact also links the module a `python -m pkg.mod` spawn runs; 67: libcb, a library summarised to call a client member back (Python library-callbacks.dl), walked as fw_edge "library callback"; 66: imports_fact names a module from its package root (a src layout) and has each test file import the conftest.py files above it; 65: guard_only also reads a conjunctive guard (`cond and __name__ == "__main__"`); 64: guard_only, a module body's call made only under `if __name__ == "__main__":`, which the import walk does not take; 63: imports_fact resolves an import written with its extension and a workspace package's specifier, and test_method takes no JavaScript / TypeScript helper by its name; 62: state_gate, state_gate_alloc, state_call_alloc, state_call_open, state_world, the callbacks one instance was given and the allocation each caller's receiver may be (JavaScript instance-state.dl); 61: filter_run, a servlet filter a test's context holds (added to HttpSecurity in a configuration the test loads, or a component) and the test methods that send a request through it; 57: a TypeScript object literal key is a ref of entity kind OBJECT_PROPERTY_KEY, kept past a bound access on its line; 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 + IMPACT_VERSION = '70' # 70: fn_returns / fn_returns_param / decorated_call (a by-name decorator hop lands on the wrapper the decorator returns), a decoration string naming a parameter of the declaration is no registration key; 69: a library-callback site is a library receiver only where a hand-back from it is summarised (ax_edges.library_receiver_sites); 68: both 67s below, from two branches; 67: spawns_fact also links the module a `python -m pkg.mod` spawn runs; 67: libcb, a library summarised to call a client member back (Python library-callbacks.dl), walked as fw_edge "library callback"; 66: imports_fact names a module from its package root (a src layout) and has each test file import the conftest.py files above it; 65: guard_only also reads a conjunctive guard (`cond and __name__ == "__main__"`); 64: guard_only, a module body's call made only under `if __name__ == "__main__":`, which the import walk does not take; 63: imports_fact resolves an import written with its extension and a workspace package's specifier, and test_method takes no JavaScript / TypeScript helper by its name; 62: state_gate, state_gate_alloc, state_call_alloc, state_call_open, state_world, the callbacks one instance was given and the allocation each caller's receiver may be (JavaScript instance-state.dl); 61: filter_run, a servlet filter a test's context holds (added to HttpSecurity in a configuration the test loads, or a component) and the test methods that send a request through it; 57: a TypeScript object literal key is a ref of entity kind OBJECT_PROPERTY_KEY, kept past a bound access on its line; 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 @@ -1745,6 +1745,18 @@ class Impact: a, b = bym.get(r[0]), bym.get(r[1]) if a and b and a != b: deconame.append((a, b)) W('decorated_name', sorted(set(deconame))) + # …and where the engine could NOT resolve the decoration, what the decorator the name denotes RETURNS, so the + # by-name decorator hop (dl/impact.dl `deco_wrapper`) lands on the wrapper the callers run, not on the + # decorator's own body: `fn_returns(m, w)` m returns the function w, `fn_returns_param(m)` m returns an argument + # it was handed (the registering `return f`), `decorated_call(s, d)` the decoration is written as a call + # (`@route("/x")`: the decorator applied is what `route` returned, not `route`). + bym = {sy['method_id']: i for i, sy in g.sym.items() if sy.get('method_id')} + W('fn_returns', sorted({(bym[a], bym[b]) for a, b in (g.q("SELECT c1, c2 FROM ext_method_returns_method") if g.has('ext_method_returns_method') else []) + if a in bym and b in bym and a != b})) + W('fn_returns_param', sorted({(bym[a],) for (a,) in (g.q("SELECT c1 FROM ext_method_returns_param") if g.has('ext_method_returns_param') else []) + if a in bym})) + W('decorated_call', sorted({(r[0], r[1].split('.')[-1]) for r in (g.q("SELECT owner_id, name, text FROM decorations") if g.has('decorations') else []) + if r[1] and re.match(r'\s*@?\s*[\w.]+\s*\(', r[2] or '')})) # ── the two spellings of one route ────────────────────────────────────────────────────────────────────── # A test asks for `/orders/o-1/price`; the handler is registered as `/orders/{order_id}/price`. Neither # string contains the other, and no call site joins them — the router does, at run time, by matching the diff --git a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl index 6c5f1978..e0664841 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl +++ b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl @@ -1069,7 +1069,35 @@ fw_edge(a, b, "spawns") :- spawns_fact(a, b), a != b. .decl dec_decl(d:symbol, w:symbol) dec_decl(d, w) :- decorated(_, d), named(d, w), 1 = count : { named(d, _) }. dec_decl(d, w) :- decorated(s, d), named(d, w), decl_file(s, f), decl_file(w, f). -fw_edge(a, b, "decorator by name") :- decorated(a, d), dec_decl(d, b), a != b. +// WHO RUNS WHAT THE DECORATOR RETURNED, NOT WHO WAS DECORATED. That hop, taken from the decorated declaration to +// the decorator's body, made the decorated function a caller of the decorator: `@bp.route("/") def index` put `index`, +// and through it every test that requests "/", above everything `route` calls. Nothing runs `route` when `index` is +// called: the decoration ran it once, where the `def` is. What a caller of the decorated NAME runs is whatever the +// decorator RETURNED for it: a wrapper (`def wrapper(*a): … return f(*a)`, which may reach the change), or the +// function itself (`return f`, a registration), which adds nothing. Where the decoration is written as a call +// (`@route("/")`), the decorator applied is what `route` returned, and it is THAT one's return the name denotes. +// fn_returns(m, w) m returns the function w (Python ext_method_returns_method) +// fn_returns_param(m) m returns an argument it was handed +// decorated_call(s, d) s carries the decoration d written as a call +// Where what the decorator returns is unknown, the hop stays as it was: the walk keeps a route it cannot judge. +.decl fn_returns(m:symbol, w:symbol) .input fn_returns +.decl fn_returns_param(m:symbol) .input fn_returns_param +.decl decorated_call(s:symbol, d:symbol) .input decorated_call +.decl deco_applied(s:symbol, x:symbol) +deco_applied(s, b) :- decorated(s, d), dec_decl(d, b), !decorated_call(s, d). +deco_applied(s, x) :- decorated(s, d), dec_decl(d, b), decorated_call(s, d), fn_returns(b, x). +.decl deco_wrapper(s:symbol, w:symbol) +deco_wrapper(s, w) :- deco_applied(s, x), fn_returns(x, w), w != s. +.decl deco_known(s:symbol, d:symbol) +deco_known(s, d) :- decorated(s, d), dec_decl(d, b), !decorated_call(s, d), fn_returns(b, _). +deco_known(s, d) :- decorated(s, d), dec_decl(d, b), !decorated_call(s, d), fn_returns_param(b). +deco_known(s, d) :- decorated(s, d), dec_decl(d, b), decorated_call(s, d), fn_returns(b, x), fn_returns(x, _). +deco_known(s, d) :- decorated(s, d), dec_decl(d, b), decorated_call(s, d), fn_returns(b, x), fn_returns_param(x). +// The decorated declaration stands for its wrapper: whatever reaches the name (a call, a route, a registry key) runs it. +// The engine leaves a call to a name whose decorator it could not resolve unresolved, so the hop is taken from the +// declaration itself rather than from its callers. +fw_edge(s, w, "decorator by name") :- deco_wrapper(s, w). +fw_edge(a, b, "decorator by name") :- decorated(a, d), dec_decl(d, b), a != b, !deco_known(a, d). // A CLIENT ACROSS A PROCESS: the sender depends on the handler it reaches (#1293). `remote` above makes the client a // DIRECT row; walked only there, a test that drives the client never reached a change to the handler, so // test-impact stopped at the route while impact named the client one line up. Walked here as its own tier, the diff --git a/tests/cases/python/decorator-by-name-runs-its-wrapper/case.json b/tests/cases/python/decorator-by-name-runs-its-wrapper/case.json new file mode 100644 index 00000000..8fc2466f --- /dev/null +++ b/tests/cases/python/decorator-by-name-runs-its-wrapper/case.json @@ -0,0 +1,26 @@ +{ + "lang": "python", + "src": ".", + "checks": [ + { + "why": "a decorator matched only by its name that REGISTERS the function and hands it back unchanged: calling the decorated function never runs the decorator, so a change to what the decorator calls does not reach the decorated function (it used to be listed as a caller of the decorator, and everything reaching it with it)", + "run": [ + "impact", + "remember" + ], + "avoid": [ + "on_order_created" + ] + }, + { + "why": "the control: a decorator matched only by its name that hands back a WRAPPER. The decorated name now denotes the wrapper, so a change the wrapper reaches still reaches the decorated declaration", + "run": [ + "impact", + "tick" + ], + "want": [ + "crunch_numbers" + ] + } + ] +} \ No newline at end of file diff --git a/tests/cases/python/decorator-by-name-runs-its-wrapper/handlers.py b/tests/cases/python/decorator-by-name-runs-its-wrapper/handlers.py new file mode 100644 index 00000000..1e560ecd --- /dev/null +++ b/tests/cases/python/decorator-by-name-runs-its-wrapper/handlers.py @@ -0,0 +1,12 @@ +from loader import load + +tools = load() + + +@tools.registered("order-created") +def on_order_created(order): + return order + + +def dispatch(kind, payload): + return tools.REGISTRY[kind](payload) diff --git a/tests/cases/python/decorator-by-name-runs-its-wrapper/loader.py b/tests/cases/python/decorator-by-name-runs-its-wrapper/loader.py new file mode 100644 index 00000000..f0d989ca --- /dev/null +++ b/tests/cases/python/decorator-by-name-runs-its-wrapper/loader.py @@ -0,0 +1,2 @@ +def load(): + return __import__("tools") diff --git a/tests/cases/python/decorator-by-name-runs-its-wrapper/tests/test_handlers.py b/tests/cases/python/decorator-by-name-runs-its-wrapper/tests/test_handlers.py new file mode 100644 index 00000000..09b903cd --- /dev/null +++ b/tests/cases/python/decorator-by-name-runs-its-wrapper/tests/test_handlers.py @@ -0,0 +1,5 @@ +from handlers import dispatch + + +def test_dispatch(): + assert dispatch("order-created", 1) == 1 diff --git a/tests/cases/python/decorator-by-name-runs-its-wrapper/tests/test_work.py b/tests/cases/python/decorator-by-name-runs-its-wrapper/tests/test_work.py new file mode 100644 index 00000000..63ad00a4 --- /dev/null +++ b/tests/cases/python/decorator-by-name-runs-its-wrapper/tests/test_work.py @@ -0,0 +1,5 @@ +from work import run_crunch + + +def test_crunch(): + assert run_crunch() == 6 diff --git a/tests/cases/python/decorator-by-name-runs-its-wrapper/tools.py b/tests/cases/python/decorator-by-name-runs-its-wrapper/tools.py new file mode 100644 index 00000000..093c1318 --- /dev/null +++ b/tests/cases/python/decorator-by-name-runs-its-wrapper/tools.py @@ -0,0 +1,25 @@ +"""Two decorators the project declares: one hands back a wrapper, one registers and hands back the function.""" +REGISTRY = {} + + +def timed(fn): + def wrapper(*args): + tick() + return fn(*args) + return wrapper + + +def tick(): + return 1 + + +def registered(name): + def register(fn): + REGISTRY[name] = fn + remember(name) + return fn + return register + + +def remember(name): + return name diff --git a/tests/cases/python/decorator-by-name-runs-its-wrapper/work.py b/tests/cases/python/decorator-by-name-runs-its-wrapper/work.py new file mode 100644 index 00000000..372c130d --- /dev/null +++ b/tests/cases/python/decorator-by-name-runs-its-wrapper/work.py @@ -0,0 +1,13 @@ +"""The decorator is reached through a value the graph cannot type, so the decoration is matched by its name.""" +from loader import load + +tools = load() + + +@tools.timed +def crunch_numbers(n): + return n * 2 + + +def run_crunch(): + return crunch_numbers(3) diff --git a/tests/cases/python/decorator-the-project-declares/case.json b/tests/cases/python/decorator-the-project-declares/case.json index 0fcb2134..ee7d61f0 100644 --- a/tests/cases/python/decorator-the-project-declares/case.json +++ b/tests/cases/python/decorator-the-project-declares/case.json @@ -3,7 +3,7 @@ "src": ".", "checks": [ { - "why": "a decorator the project declares is a dependency of everything it wraps: `@exporter(\"csv\") def export_csv` means export_csv IS what exporter returned, so a test that drives the handler must run when the decorator changes — and nothing but the @ line says so, since the decorator has no call site and the table is read by a key. The control is a module of its OWN: a sibling in the same file is reached whatever the decorator does, because the decoration runs while that file is being imported", + "why": "a decorator the project declares is a dependency of everything it wraps' MODULE: `@exporter(\"csv\") def export_csv` runs exporter(\"csv\") and the function it returned while app.py is being imported, so a test that imports app must run when the decorator changes. Calling export_csv later never runs exporter (it registered the function and handed it back unchanged), so the route is the import, not a hop from the decorated function. The control is a module of its OWN: a sibling in the same file is reached whatever the decorator does, because the decoration runs while that file is being imported", "run": [ "impact", "exporter", @@ -11,7 +11,7 @@ ], "want": [ "test_export.py", - "decorator by name" + "at import" ], "avoid": [ "test_plain.py" @@ -32,14 +32,14 @@ ] }, { - "why": "the hop is named for what it is. The decoration carries a simple NAME, so the declaration it denotes is matched by that name and not resolved, and the answer must not sell that as a resolved call", + "why": "the hop is named for what it is: the decoration runs at import, and the answer says so rather than selling it as a resolved call", "run": [ "impact", "exporter", "--tests" ], "want": [ - "decorator by name" + "at import" ], "avoid": [ "how sure each route is: 3 sound" diff --git a/tests/cases/python/option-parameter-name-is-no-key/case.json b/tests/cases/python/option-parameter-name-is-no-key/case.json new file mode 100644 index 00000000..dd288be8 --- /dev/null +++ b/tests/cases/python/option-parameter-name-is-no-key/case.json @@ -0,0 +1,16 @@ +{ + "lang": "python", + "src": ".", + "checks": [ + { + "why": "a decoration string that names a parameter of the declaration it decorates binds that parameter; it is not a key anything dispatches on. Every dict with a `level` key wrote it, and was joined to the command as a caller", + "run": ["impact", "describe", "--tests"], + "avoid": ["defaults", "test_config.py"] + }, + { + "why": "the control: the flag is what a caller writes to reach the command, and a test that writes it still reaches the change", + "run": ["impact", "describe", "--tests"], + "want": ["test_cli.py"] + } + ] +} diff --git a/tests/cases/python/option-parameter-name-is-no-key/cli.py b/tests/cases/python/option-parameter-name-is-no-key/cli.py new file mode 100644 index 00000000..0bd55772 --- /dev/null +++ b/tests/cases/python/option-parameter-name-is-no-key/cli.py @@ -0,0 +1,17 @@ +"""A command whose decorations declare its parameters: the flags are what a caller writes, the last name is the +parameter the value is bound to.""" + + +def option(*decls, **settings): + def attach(fn): + return fn + return attach + + +@option("--level", "-l", "level", type=int) +def main(level=0): + return describe(level) + + +def describe(level): + return level diff --git a/tests/cases/python/option-parameter-name-is-no-key/config.py b/tests/cases/python/option-parameter-name-is-no-key/config.py new file mode 100644 index 00000000..6cf1e6bb --- /dev/null +++ b/tests/cases/python/option-parameter-name-is-no-key/config.py @@ -0,0 +1,2 @@ +def defaults(): + return {"level": 3} diff --git a/tests/cases/python/option-parameter-name-is-no-key/tests/test_cli.py b/tests/cases/python/option-parameter-name-is-no-key/tests/test_cli.py new file mode 100644 index 00000000..9becc312 --- /dev/null +++ b/tests/cases/python/option-parameter-name-is-no-key/tests/test_cli.py @@ -0,0 +1,6 @@ +from cli import main + + +def test_level_flag(): + args = ["--level", "2"] + assert main(int(args[1])) == 2 diff --git a/tests/cases/python/option-parameter-name-is-no-key/tests/test_config.py b/tests/cases/python/option-parameter-name-is-no-key/tests/test_config.py new file mode 100644 index 00000000..b5c2cfb5 --- /dev/null +++ b/tests/cases/python/option-parameter-name-is-no-key/tests/test_config.py @@ -0,0 +1,5 @@ +from config import defaults + + +def test_defaults(): + assert defaults()["level"] == 3 From 65510f08f59be214eed1e9ed2f4ebd92dbfcb70d Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:42:27 -0700 Subject: [PATCH 06/11] fix(python-engine): a class-object parameter under a union constructs its class `cls: type[X] | None = None` and `Optional[Type[X]]` are the default-None spelling of a class-object parameter: the body swaps None for a default class and calls `cls(...)`. Only a top-level `type[...]` annotation was read, so the call stayed `callee_is_parameter` and every constructor behind it lost that caller (a decorator factory building its command class in a closure is the common shape). param_class_object_ref now names the `type[...]` subscript an annotation is or one of its union operands is, and both the concrete (expr_type_class_object) and the bounded-TypeVar (param_class_object_bound) rules read it. The existing top-level rules are unchanged. Corpus: one-hop callers src 0.828 -> 0.829, path found 0.727 -> 0.729, test->target path 0.559 -> 0.562; reach and tests unchanged; engine suite and torture unchanged (agree 588 / missing 46 / extra 37); index time flat. Case: optional-class-object-parameter (fails on the base; the bare `type[Command]` control passes on both). Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- graph/python/engine/resolution/generics.dl | 7 +++++ graph/python/engine/resolution/value-flow.dl | 22 +++++++++++++ graph/python/souffle/decls_all.dl | 1 + .../optional-class-object-parameter/case.json | 16 ++++++++++ .../commands.py | 31 +++++++++++++++++++ 5 files changed, 77 insertions(+) create mode 100644 tests/cases/python/optional-class-object-parameter/case.json create mode 100644 tests/cases/python/optional-class-object-parameter/commands.py diff --git a/graph/python/engine/resolution/generics.dl b/graph/python/engine/resolution/generics.dl index 20e9e93e..964f502a 100644 --- a/graph/python/engine/resolution/generics.dl +++ b/graph/python/engine/resolution/generics.dl @@ -279,6 +279,13 @@ param_class_object_bound("client", ph, t) :- type_ref_nesting("client", r, _, "1", child), type_ref("client", "TYPE_VAR", _, vn, _, child), vn != "", typevar_bound("client", vn, t). +// …and the same under a union (`cls: type[CmdType] | None = None`): see param_class_object_ref. +param_class_object_bound("client", ph, t) :- + param_class_object_ref("client", ph, s), + type_ref_owner("client", ph, "METHOD_PARAM", r), s != r, + type_ref_nesting("client", s, _, _, child), + type_ref("client", "TYPE_VAR", _, vn, _, child), vn != "", + typevar_bound("client", vn, t). expr_type_class_object("client", e, t) :- expr_names_param("client", e, ph), param_class_object_bound("client", ph, t). diff --git a/graph/python/engine/resolution/value-flow.dl b/graph/python/engine/resolution/value-flow.dl index 6fd224af..51b73f51 100644 --- a/graph/python/engine/resolution/value-flow.dl +++ b/graph/python/engine/resolution/value-flow.dl @@ -450,6 +450,22 @@ expr_type_class_object(p, e, t) :- annotation_names_a_class("type"). annotation_names_a_class("Type"). +// ── param_class_object_ref(Prov, ParamHash, SubscriptRef) ───────────────────── +// The `type[...]` subscript a parameter's annotation IS, or one operand of its union is. +// `cls: type[Command] | None = None` (and `Optional[type[Command]]`) is the default-None +// spelling of the same class-object parameter: the body replaces None with a default class +// and calls `cls(...)`. Read only at the top level, that call stayed unresolved, and so did +// every constructor reached through it. +param_class_object_ref("client", ph, r) :- + type_ref_owner("client", ph, "METHOD_PARAM", r), + type_ref("client", _, "METHOD_PARAM", tn, _, r), + annotation_names_a_class(tn). +param_class_object_ref("client", ph, s) :- + type_ref_owner("client", ph, "METHOD_PARAM", r), + union_operand("client", r, s), + type_ref("client", _, _, tn, _, s), + annotation_names_a_class(tn). + expr_type_class_object("client", e, t) :- expr_names_param("client", e, ph), type_ref_owner("client", ph, "METHOD_PARAM", r), @@ -457,6 +473,12 @@ expr_type_class_object("client", e, t) :- annotation_names_a_class(tn), type_ref_nesting("client", r, _, "1", child), type_ref_resolved("client", t, child). +expr_type_class_object("client", e, t) :- + expr_names_param("client", e, ph), + param_class_object_ref("client", ph, s), + type_ref_owner("client", ph, "METHOD_PARAM", r), s != r, + type_ref_nesting("client", s, _, _, child), + type_ref_resolved("client", t, child). // A name written MORE THAN ONCE takes the union, for the same reason the instance side // does: a sound set beats a blank, and the tier follows from the target count. diff --git a/graph/python/souffle/decls_all.dl b/graph/python/souffle/decls_all.dl index 08cc0035..db011f11 100644 --- a/graph/python/souffle/decls_all.dl +++ b/graph/python/souffle/decls_all.dl @@ -244,6 +244,7 @@ .decl binding_element_lib_type(c0:symbol,c1:symbol) .decl param_declared_type_by_parser(c0:symbol,c1:symbol,c2:symbol) .decl union_operand(c0:symbol,c1:symbol,c2:symbol) +.decl param_class_object_ref(c0:symbol,c1:symbol,c2:symbol) .decl type_ref_element(c0:symbol,c1:symbol,c2:symbol) .decl annotation_owner_module(c0:symbol,c1:symbol,c2:symbol,c3:symbol) .decl annotation_container_kind(c0:symbol) diff --git a/tests/cases/python/optional-class-object-parameter/case.json b/tests/cases/python/optional-class-object-parameter/case.json new file mode 100644 index 00000000..b0f17354 --- /dev/null +++ b/tests/cases/python/optional-class-object-parameter/case.json @@ -0,0 +1,16 @@ +{ + "lang": "python", + "src": ".", + "checks": [ + { + "why": "a class-object parameter spelled with its default (`cls: type[CmdType] | None = None`, `Optional[Type[Command]]`) holds a class just as `type[Command]` does: `cls(name)` constructs it, so the constructor's callers include the code that calls through the parameter, in a closure or not", + "run": ["impact", "Command.__init__"], + "want": ["decorator", "option"] + }, + { + "why": "the control: the bare `type[Command]` spelling was already read", + "run": ["impact", "Command.__init__"], + "want": ["fixed"] + } + ] +} diff --git a/tests/cases/python/optional-class-object-parameter/commands.py b/tests/cases/python/optional-class-object-parameter/commands.py new file mode 100644 index 00000000..7ec1b9f1 --- /dev/null +++ b/tests/cases/python/optional-class-object-parameter/commands.py @@ -0,0 +1,31 @@ +"""A factory whose class parameter defaults to None: the body puts the default class in and constructs it.""" +import typing as t + + +class Command: + def __init__(self, name): + self.name = name + + +class Group(Command): + pass + + +CmdType = t.TypeVar("CmdType", bound=Command) + + +def command(name, cls: type[CmdType] | None = None): + if cls is None: + cls = t.cast("type[CmdType]", Command) + + def decorator(f): + return cls(name) + return decorator + + +def option(name, cls: t.Optional[t.Type[Command]] = None): + return (cls or Command)(name) + + +def fixed(name, cls: type[Command]): + return cls(name) From f0edf90a89f7eab74e641f71ee3fdee3aab79b03 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:13:48 -0700 Subject: [PATCH 07/11] =?UTF-8?q?feat(links):=20agent-asserted=20call=20ed?= =?UTF-8?q?ges=20=E2=80=94=20list=20unknown=20sites,=20link=20them,=20walk?= =?UTF-8?q?=20them=20as=20[asserted]=20(#1892)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An answer that stops at a call the graph could not resolve now lists the site (file:line:col, the call as written, the engine's reason). axiomcode link records where it lands in axiomcode-links.tsv; the graph validates each link (a call there consistent with the target, the target declared, the line text unchanged) and applies it as a call edge of tier asserted, never resolved. Links only add edges, survive rebuilds, follow their line by its text within the callable, and drop (reported) when the line is edited. A link whose target declares a return type also resolves calls chained on the result and calls on a local assigned from it. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- plugins/axiomcode/AGENTS.md | 2 + plugins/axiomcode/mcp/server.py | 9 + plugins/axiomcode/rules/axiomcode.mdc | 2 + plugins/axiomcode/skills/axiomcode/SKILL.md | 14 + .../skills/axiomcode/scripts/ax_blocks.py | 16 +- .../skills/axiomcode/scripts/ax_edges.py | 8 +- .../skills/axiomcode/scripts/ax_fresh.py | 6 + .../skills/axiomcode/scripts/ax_links.py | 1006 +++++++++++++++++ .../skills/axiomcode/scripts/axiomcode | 6 +- .../skills/axiomcode/scripts/axiomcode-impact | 31 +- .../skills/axiomcode/scripts/axiomcode-index | 18 +- .../skills/axiomcode/scripts/axiomcode-link | 17 + .../skills/axiomcode/scripts/axiomcode-path | 25 + skills/axiomcode/SKILL.md | 14 + .../asserted-links/App.Tests/DispatchTests.cs | 15 + .../csharp/asserted-links/App/Dispatch.cs | 31 + .../csharp/asserted-links/App/Handlers.cs | 28 + tests/cases/csharp/asserted-links/case.json | 169 +++ tests/cases/java/asserted-links/case.json | 161 +++ .../java/asserted-links/src/pkg/Dispatch.java | 25 + .../asserted-links/src/pkg/DispatchTest.java | 20 + .../java/asserted-links/src/pkg/Handlers.java | 22 + .../cases/javascript/asserted-links/case.json | 170 +++ .../javascript/asserted-links/src/dispatch.js | 25 + .../asserted-links/src/dispatch.test.js | 12 + .../javascript/asserted-links/src/handlers.js | 18 + .../asserted-links-derive/app/__init__.py | 0 .../python/asserted-links-derive/app/route.py | 47 + .../asserted-links-derive/app/runners.py | 8 + .../python/asserted-links-derive/app/views.py | 37 + .../python/asserted-links-derive/case.json | 53 + .../python/asserted-links/app/__init__.py | 0 .../python/asserted-links/app/dispatch.py | 23 + .../python/asserted-links/app/handlers.py | 15 + tests/cases/python/asserted-links/case.json | 229 ++++ .../asserted-links/tests/test_dispatch.py | 10 + .../cases/typescript/asserted-links/case.json | 169 +++ .../asserted-links/src/dispatch.test.ts | 12 + .../typescript/asserted-links/src/dispatch.ts | 20 + .../typescript/asserted-links/src/handlers.ts | 18 + tests/front_door.py | 4 +- tests/mcp.py | 2 +- tests/run.py | 15 + tests/surfaces.py | 2 +- 44 files changed, 2517 insertions(+), 17 deletions(-) create mode 100644 plugins/axiomcode/skills/axiomcode/scripts/ax_links.py create mode 100755 plugins/axiomcode/skills/axiomcode/scripts/axiomcode-link create mode 100644 tests/cases/csharp/asserted-links/App.Tests/DispatchTests.cs create mode 100644 tests/cases/csharp/asserted-links/App/Dispatch.cs create mode 100644 tests/cases/csharp/asserted-links/App/Handlers.cs create mode 100644 tests/cases/csharp/asserted-links/case.json create mode 100644 tests/cases/java/asserted-links/case.json create mode 100644 tests/cases/java/asserted-links/src/pkg/Dispatch.java create mode 100644 tests/cases/java/asserted-links/src/pkg/DispatchTest.java create mode 100644 tests/cases/java/asserted-links/src/pkg/Handlers.java create mode 100644 tests/cases/javascript/asserted-links/case.json create mode 100644 tests/cases/javascript/asserted-links/src/dispatch.js create mode 100644 tests/cases/javascript/asserted-links/src/dispatch.test.js create mode 100644 tests/cases/javascript/asserted-links/src/handlers.js create mode 100644 tests/cases/python/asserted-links-derive/app/__init__.py create mode 100644 tests/cases/python/asserted-links-derive/app/route.py create mode 100644 tests/cases/python/asserted-links-derive/app/runners.py create mode 100644 tests/cases/python/asserted-links-derive/app/views.py create mode 100644 tests/cases/python/asserted-links-derive/case.json create mode 100644 tests/cases/python/asserted-links/app/__init__.py create mode 100644 tests/cases/python/asserted-links/app/dispatch.py create mode 100644 tests/cases/python/asserted-links/app/handlers.py create mode 100644 tests/cases/python/asserted-links/case.json create mode 100644 tests/cases/python/asserted-links/tests/test_dispatch.py create mode 100644 tests/cases/typescript/asserted-links/case.json create mode 100644 tests/cases/typescript/asserted-links/src/dispatch.test.ts create mode 100644 tests/cases/typescript/asserted-links/src/dispatch.ts create mode 100644 tests/cases/typescript/asserted-links/src/handlers.ts diff --git a/plugins/axiomcode/AGENTS.md b/plugins/axiomcode/AGENTS.md index f85c960e..4d8b8abb 100644 --- a/plugins/axiomcode/AGENTS.md +++ b/plugins/axiomcode/AGENTS.md @@ -8,6 +8,8 @@ that never spell the name: impact() with no name: the same for your uncommitted edits path(start, end) how A reaches B, every hop of the call chain tests() the tests your uncommitted edits reach, and the command that runs them + link(site, target) record where an unresolved call lands, when the code makes it certain; + impact, path and tests then walk it, labelled [asserted] context(task) how something works, as a narrative: the call flow step by step; context(task, source=True) carries each step's code diff --git a/plugins/axiomcode/mcp/server.py b/plugins/axiomcode/mcp/server.py index cfd5138e..5081fcbb 100755 --- a/plugins/axiomcode/mcp/server.py +++ b/plugins/axiomcode/mcp/server.py @@ -243,6 +243,15 @@ def path(start: str, end: str) -> str: written on. start / end as written in the code (Owner.method, function, Type).""" return plain(run(['path', start, end, os.getcwd()])) +@srv.tool() +def link(site: str = '', target: str = '') -> str: + """Record where an unresolved call lands, when you have read the code and the target is CERTAIN: site is the call's + file:line as an answer's `unknown:` block lists it, target the declaration it reaches (Owner.method, function, or + its file:line). impact, path and tests then walk the edge, labelled [asserted]. With no arguments: every link and + whether the graph took it (a link whose line changed is dropped, never trusted). target "-" removes the site's links. + Never link a guess.""" + return plain(run(['link'] + ([site] if site.strip() else []) + ([target] if site.strip() and target.strip() else []) + [os.getcwd()])) + @srv.tool() def tests() -> str: """The tests your uncommitted edits reach, each with its code, and the command that runs exactly those.""" diff --git a/plugins/axiomcode/rules/axiomcode.mdc b/plugins/axiomcode/rules/axiomcode.mdc index 0432af2d..d36b30c5 100644 --- a/plugins/axiomcode/rules/axiomcode.mdc +++ b/plugins/axiomcode/rules/axiomcode.mdc @@ -13,6 +13,8 @@ that never spell the name: impact() with no name: the same for your uncommitted edits path(start, end) how A reaches B, every hop of the call chain tests() the tests your uncommitted edits reach, and the command that runs them + link(site, target) record where an unresolved call lands, when the code makes it certain; + impact, path and tests then walk it, labelled [asserted] context(task) how something works, as a narrative: the call flow step by step; context(task, source=True) carries each step's code diff --git a/plugins/axiomcode/skills/axiomcode/SKILL.md b/plugins/axiomcode/skills/axiomcode/SKILL.md index b3a302d2..c92f8060 100644 --- a/plugins/axiomcode/skills/axiomcode/SKILL.md +++ b/plugins/axiomcode/skills/axiomcode/SKILL.md @@ -18,6 +18,7 @@ Search with grep as usual; the graph answers what grep cannot. Use the MCP tools | what do my uncommitted edits reach? | `impact()` | `axiomcode impact` | | how does A reach B? | `path(start, end)` | `axiomcode path ` | | which tests do my edits need, and how do I run them? | `tests()` | `axiomcode tests` | +| an answer lists an unresolved call I can see the target of | `link(site, target)` | `axiomcode link ` | | how does this work, start to finish? | `context(task, source=True)` | `axiomcode context "" --source` | Names are written as in the code: `Owner.method`, `function`, `Type`, or `file.py:123` for the declaration at that @@ -59,6 +60,19 @@ Example: `path(start="main", end="Ledger.put")`. The tests your uncommitted edits reach, each with its code, and a last line `run: ` that runs exactly those. Example: `tests()`. It is a lower bound: a test reached only through reflection or a service loader is not listed. +## link + +An answer that stops at a call the graph could not resolve lists it under `unknown:` — the site as `file:line`, the +call as written and why the engine could not follow it (a value from `getattr`, a handler table, reflection, a +callback). When the task depends on one of those sites, read the code; if it makes the target CERTAIN, record it: +`link(site="app/dispatch.py:6", target="on_save")`, or `axiomcode link app/dispatch.py:6 on_save` from the shell. +From then on impact, path and tests walk that edge, labelled `[asserted]`, never `resolved`. The links are kept in +`axiomcode-links.tsv` at the repository root, which is worth committing. `link()` with no arguments lists them and +whether the graph took each one; `axiomcode link -` removes one. A link is refused when the call written +there names a different declaration, or the target is not one; when the line it was made on is edited, it is +dropped and listed as stale, and the site is unknown again. Never link a guess: an asserted edge is trusted by every +answer after it. + ## context How something works, from a task in your own words: the files and callables the task touches and, for a diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_blocks.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_blocks.py index 6fc1541c..0572a4e4 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_blocks.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_blocks.py @@ -172,7 +172,17 @@ def greppable(p): # from these places needs it as much as the verified: line, so it is never tidied away here. # run: stays LAST: the answer ends with the command to run, whatever else the foot carries. kept = [x for x in foot if x.startswith(('verified', 'bound:'))][:3] - out += kept + [x for x in foot if x.startswith('run:')][:1] + out += kept + unknown(doc, repo) + [x for x in foot if x.startswith('run:')][:1] + return out + + +UNKNOWN_SHOWN = 5 # unresolved sites listed under an answer: the nearest; the verbs' --json carries up to 30 +def unknown(doc, repo): + """the answer's gaps as a short work list (ax_links.py): where it stops being complete, and how to close one""" + import ax_links + sites = doc.get('unknown_sites') or [] + out = ax_links.unknown_lines(repo, sites, doc.get('unknown_total') or len(sites), shown=UNKNOWN_SHOWN) if sites else [] + if doc.get('links_note'): out.append(doc['links_note']) return out @@ -258,7 +268,9 @@ def main(argv): lines = render(verb, doc, repo) if r.returncode in (0, 1) or doc.get('called_undeclared') else None if lines is None: # a refusal or an answer with no place in it: the verb's own words are the answer - print('\n'.join(doc.get('prose') or []) or doc.get('refusal') or r.stdout.strip()); return r.returncode + prose = '\n'.join(doc.get('prose') or []) or doc.get('refusal') or r.stdout.strip() + extra = [l for l in unknown(doc, repo) if l not in prose] + print('\n'.join([prose] + extra)); return r.returncode print('\n'.join(lines)) return 0 diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py index b85276e6..028d57fd 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py @@ -47,6 +47,7 @@ 'dispatch': 2, # a base method to an override that is actually instantiated 'callback_registered': 3, # handed over as a value and invoked by whoever holds it 'event_dispatch': 3, # emitted here, handled there + 'asserted': 3, # a link someone recorded (axiomcode link) where the engine resolved nothing: read, not derived 'remote': 5, # a request crosses a process to its handler (remote_edge): no call site names it. 'framework': 5, # a framework runs the other end for this one (framework_edge). Both 5, the default # impact's route reader already gave them (P.TIER_RANK.get(t, 5)), so its routes do not move @@ -69,6 +70,7 @@ 'dispatch': 'a base method to an override the project instantiates', 'callback_registered': 'handed over as a value and invoked by whoever holds it', 'event_dispatch': 'emitted here, handled there', + 'asserted': 'ASSERTED by a link (axiomcode-links.tsv): someone read the call and recorded its target; the engine did not resolve it', 'remote': 'NOT a call site: a request crosses a process to the handler that serves it (transport and destination on the hop)', 'framework': 'NOT a call site: a framework runs the other end for this one (mechanism and registration on the hop)', 'defines': 'NOT a call — written inside that body, so it runs only after it', @@ -176,6 +178,7 @@ def legend(tiers): 'callback_registered': 'registered', 'event_dispatch': 'registered', 'ambient_terminal': 'registered', 'dynamic_terminal': 'registered', 'intrinsic_terminal': 'registered', 'fan_capped': 'capped set', + 'asserted': 'asserted', # a link someone recorded (ax_links.py): an edge, never `resolved` 'stub': 'stubs it', # a call inside a mock's stub or verification (stub_sites below): named, never run 'remote': 'remote', 'framework': 'framework', # impact's own rung names for the same two hops (#1469) } @@ -185,6 +188,7 @@ def legend(tiers): DIRECT_WHY = { 'registered': 'handed over as a value — the engine recorded the hand-off, not a call site', 'capped set': 'calls it, as one of a candidate set too large to enumerate — this is a sample of that set', + 'asserted': 'calls it — asserted by a link (axiomcode-links.tsv), not resolved by the engine', 'stubs it': 'stubs it on a mock: the real method does not run there, and the test breaks only if the name or parameters change', } # …and where the TIER says something more specific than its certainty. A request or event is not handed over as a @@ -263,12 +267,12 @@ def entry_outside(reason): # instead: the membership is exactly what it was before this table existed, so no row leaves any # set — only the label it is printed under changes. It matters most for the --delete verdict, where # dropping a hand-off would turn "something still holds this" into "safe to delete". -EDGE_BACKED = frozenset({'resolved', 'one of a set', 'registered', 'capped set', 'stubs it'}) +EDGE_BACKED = frozenset({'resolved', 'one of a set', 'registered', 'asserted', 'capped set', 'stubs it'}) # most certain first. A caller with several call sites to the same callee can hold sites of different # tiers; a summary that names the caller once takes the best of them, which is the honest reading of # "at least one resolved call exists here". -DIRECT_ORDER = ('resolved', 'one of a set', 'registered', 'capped set', 'stubs it') +DIRECT_ORDER = ('resolved', 'one of a set', 'registered', 'asserted', 'capped set', 'stubs it') # ── `defines`: a callable written inside another one's body ──────────────────────────────────────────────── diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py index d763c2b4..b0192551 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py @@ -1505,6 +1505,12 @@ def query(repo, verb, argv, fresh=False): """run a query verb (argv) against the last good graph, the stale-while-revalidate way (above). Returns its exit code""" import ax_exec argv = ax_exec.program(argv) # `python3` may be a shell shim no native process can start (#1331) + # THE ASSERTED LINKS FOLLOW THEIR FILE (ax_links.py): a links file edited by hand, pulled or removed since the graphs + # were last given it is re-applied here, O(links), with the derived facts patched in place — never a rebuild + try: + import ax_links; ax_links.sync(repo) + except Exception: + pass def run(): return subprocess.run(argv, stdout=subprocess.PIPE) def passthrough(): ax_exec.become(argv) # never os.execvp: on Windows it returns 0 before the answer (#1640) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_links.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_links.py new file mode 100644 index 00000000..93dd4dc9 --- /dev/null +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_links.py @@ -0,0 +1,1006 @@ +#!/usr/bin/env python3 +"""ax_links.py — call edges an agent (or a person) ASSERTS where the graph could not resolve the call. + +An answer that stops at an unresolved site says so ("N unresolved call(s) inside — a lower bound") and lists the sites +(`unknown_sites`). Someone who has read the code and is certain where such a call lands records it: + + axiomcode link the call written at file:line reaches + axiomcode link every link, and whether the graph took it + axiomcode link - remove the links at that site (or: --remove []) + +THE FILE. Links live in `axiomcode-links.tsv` at the repository root (AXIOMCODE_LINKS overrides it), not under +.axiomcode/. Everything under .axiomcode/ is derived and is deleted freely (a corrupt graph, an engine change, a user +clearing it); a link is knowledge someone read the code to get, so it must outlive the graph, and it is reviewed and +shared like any other file a team commits. The line's text is hashed into each row, so a committed link that no longer +describes the code is dropped by the graph rather than trusted. + +THE RULE. A link may only ADD an edge, never remove or relabel one (the rule runtime-observed.dl states for a trace). +Each is validated against the graph it is applied to, and applied only if: + (a) a call site is written at that line (followed by the line's TEXT when lines above it moved: the nearest line with + the same text and a call of the same name), and the call as written is consistent with the target: the same name, + the class a constructor belongs to, or a call through a value (the name as written declares nothing in the graph, + a computed or reflective call, or a site the engine itself says is a call through a parameter or value); + (b) the target is a declaration in the graph (a client callable, or a staged library method), named as the graph + names it, in the file the link recorded; + (c) the line's text still hashes the same. +An applied link is a call_edges row of tier `asserted` (certainty `asserted`, never `resolved`). A rejected or stale +one is recorded in the graph's `asserted_links` table with the reason, listed by `axiomcode link`, and counted on the +next answer's note line. Applying is O(links): it runs at the end of every index (axiomcode-index), so a rebuild keeps +them, and on `link` itself against the existing graphs, with the derived facts patched in place (no re-solve). +""" +import hashlib, json, os, re, sqlite3, subprocess, sys, time + +FILE_NAME = 'axiomcode-links.tsv' +COLS = ('file', 'line', 'line_sha', 'callee', 'caller', 'target', 'target_file', 'by', 'at', 'col', 'ncol') +HEADER = ('# axiomcode links: call edges asserted where the graph could not resolve the call. ' + 'Written by `axiomcode link`; one per line, tab-separated: ' + ' '.join(COLS)) +TIER = 'asserted' +# a call whose callee is computed, or invoked through a value: the name as written says nothing about what runs +VALUE_KINDS = {'DYNAMIC_CALL', 'SUBSCRIPT_CALL', 'UNKNOWN_CALLEE_CALL', 'COMPUTED_CALL', 'FUNCTION_CALL_APPLY', + 'FUNCTION_CALL_CALL', 'FUNCTION_CALL_BIND', 'IIFE_CALL', 'DYNAMIC_CODE_CALL'} +# the engine's own reason (ext_call_site_unresolved) when it says the callee is a value it could not follow +VALUE_REASONS = ('callee_is_parameter', 'dynamic_call', 'escape_hatch', 'unbound_name', 'untyped_receiver:subscript_untyped', + 'member_absent_from_type', 'untyped_receiver:attribute_absent_on_type', 'computed_attribute_name', + 'value_callee', 'callee_is_value', 'callee_is_local', 'callee_is_field') +# the methods every language invokes a held callable or a reflected member through +REFLECTIVE = {'invoke', 'Invoke', 'DynamicInvoke', 'InvokeMember', 'apply', 'call', 'accept', 'test', 'run', + 'applyAsInt', 'applyAsLong', 'applyAsDouble', 'handle', 'execute', 'Execute', '__call__', 'emit', 'dispatch'} +RANK = {'applied': 0, 'moved': 1, 'redundant': 2, 'changed': 3, 'stale': 3, 'rejected': 4, 'malformed': 5} +# the library methods that run a callable or a member someone else chose: a site resolved to one of them is as unknown as an +# unresolved one, and is listed with them (unknown_sites) +REFLECTIVE_LIB = {'invoke', 'Invoke', 'DynamicInvoke', 'InvokeMember', 'newInstance', 'CreateInstance', 'apply', 'accept', + 'test', 'run', 'call', 'applyAsInt', 'applyAsLong', 'applyAsDouble', 'Execute'} +# a call written on a receiver (`x.m()`): its callee as written is a member name, not a value +MEMBER_KINDS = {'METHOD_CALL', 'SELF_CALL', 'SUPER_CALL', 'CHAINED_CALL', 'OPTIONAL_CALL', 'PROPERTY_READ', 'PROPERTY_WRITE', + 'CONTEXT_MANAGER', 'ITERATION_PROTOCOL', 'BUILTIN_PROTOCOL', 'DECORATOR_ATTRIBUTE'} +CTOR_NAMES = {'__init__', '', 'constructor', '.ctor', '__new__'} + + +def norm(text): + return re.sub(r'\s+', ' ', (text or '').strip()) + + +def line_sha(text): + return hashlib.sha1(norm(text).encode('utf-8', 'replace')).hexdigest()[:12] + + +def norm_col(text, col): + """a 1-based column of a line as an offset into the line's normalized text (norm): the same call keeps it when the + line is re-indented or its spacing changes, which is how a link follows a call by its place within the line""" + pre = re.sub(r'\s+', ' ', (text or '')[:max(0, col - 1)].lstrip()) + return len(pre) + + +def denorm_col(text, off): + """the 1-based column of the line whose normalized offset is off (norm_col's inverse), or None past the end""" + i, n, t = 0, 0, text or '' + while i < len(t) and t[i].isspace(): i += 1 + while i < len(t): + if n == off: return i + 1 + if t[i].isspace(): + while i < len(t) and t[i].isspace(): i += 1 + n += 1 + else: i += 1; n += 1 + return i + 1 if n == off else None + + +def links_path(repo): + return os.environ.get('AXIOMCODE_LINKS') or os.path.join(repo, FILE_NAME) + + +def file_sha(repo): + try: + with open(links_path(repo), 'rb') as fh: return hashlib.sha1(fh.read()).hexdigest()[:16] + except OSError: return '' + + +def read_links(repo): + """([link dict with 'n' = its line in the file], [(n, raw, why)] malformed lines). A bad line costs only itself.""" + out, bad = [], [] + try: + with open(links_path(repo), encoding='utf-8', errors='replace') as fh: rows = fh.read().split('\n') + except OSError: return out, bad + for n, raw in enumerate(rows, 1): + if not raw.strip() or raw.lstrip().startswith('#'): continue + parts = raw.split('\t') + if len(parts) < 6: + bad.append((n, raw, f'expected at least 6 tab-separated fields ({", ".join(COLS[:6])}), found {len(parts)}')); continue + d = dict(zip(COLS, parts + [''] * (len(COLS) - len(parts)))) + if not d['file'] or not d['target'] or not re.fullmatch(r'\d+', d['line'].strip() or 'x'): + bad.append((n, raw, 'the file, a numeric line and the target are required')); continue + d['line'] = int(d['line']); d['n'] = n + d['col'] = int(d['col']) if str(d.get('col') or '').isdigit() else None + d['ncol'] = int(d['ncol']) if str(d.get('ncol') or '').isdigit() else None + out.append(d) + return out, bad + + +def write_links(repo, links): + p = links_path(repo); tmp = f"{p}.{os.getpid()}.tmp" + keep = [] + try: + with open(p, encoding='utf-8', errors='replace') as fh: + keep = [l for l in fh.read().split('\n') if l.lstrip().startswith('#') and l.strip() != HEADER] + except OSError: pass + with open(tmp, 'w', encoding='utf-8') as fh: + fh.write(HEADER + '\n') + for l in keep: fh.write(l + '\n') + for d in links: fh.write('\t'.join(('' if d.get(c) is None else str(d.get(c))).replace('\t', ' ').replace('\n', ' ') for c in COLS).rstrip('\t') + '\n') + os.replace(tmp, p) + + +# ── the graphs of a repository and the text each describes ────────────────────────────────────────────────── +def graph_dbs(repo): + """[(db path, source reader, live?)] for every graph under .axiomcode: the main and each language's, and the baseline + graphs `changed` / `tests` read, whose text is the tree they were built from""" + ax = os.path.join(repo, '.axiomcode'); out, seen = [], set() + def add(db, reader, live): + try: rp = os.path.realpath(db) + except OSError: return + if os.path.isfile(rp) and rp not in seen: seen.add(rp); out.append((db, reader, live)) + work = Reader(repo) + add(os.path.join(ax, 'out', 'graph.sqlite'), work, True) + lang = os.path.join(ax, 'lang') + for l in sorted(os.listdir(lang)) if os.path.isdir(lang) else []: add(os.path.join(lang, l, 'out', 'graph.sqlite'), work, True) + base = os.path.join(ax, 'base') + if os.path.isdir(base): + try: tree = open(os.path.join(base, 'tree')).read().strip() + except OSError: tree = '' + br = Reader(repo, tree) if tree else None + if br: + add(os.path.join(base, 'out', 'graph.sqlite'), br, False) + bl = os.path.join(base, 'lang') + for l in sorted(os.listdir(bl)) if os.path.isdir(bl) else []: add(os.path.join(bl, l, 'out', 'graph.sqlite'), br, False) + return out + + +class Reader: + """a file's lines: the working tree's, or (tree given) the text of that git tree""" + def __init__(self, repo, tree=None): + self.repo, self.tree, self.cache = repo, tree, {} + def lines(self, f): + if f not in self.cache: + txt = None + if self.tree: + try: + r = subprocess.run(['git', 'cat-file', 'blob', f'{self.tree}:{f}'], cwd=self.repo, capture_output=True, timeout=20) + if r.returncode == 0: txt = r.stdout.decode('utf-8', 'replace') + except (OSError, subprocess.SubprocessError): pass + else: + try: + with open(os.path.join(self.repo, f), encoding='utf-8', errors='replace') as fh: txt = fh.read() + except OSError: pass + self.cache[f] = txt.split('\n') if txt is not None else None + return self.cache[f] + + +# ── one graph ──────────────────────────────────────────────────────────────────────────────────────────────── +class Graph: + def __init__(self, con): + self.con = con + self.tables = {r[0] for r in con.execute("SELECT name FROM sqlite_master WHERE type IN ('table','view')")} + self._raw = None; self._names = None + self.lang = '' + if 'run' in self.tables: + r = self.q("SELECT value FROM run WHERE key = 'language'") + self.lang = r[0][0] if r else '' + # call_sites columns: 1-based for the TypeScript / JavaScript front ends, 0-based for the others + self.base = 1 if self.lang in ('typescript', 'javascript') else 0 + self.reader = None + def q(self, sql, *a): return self.con.execute(sql, a).fetchall() + def raw_paths(self, f): + """the spellings call_sites.file_path uses for the repo-relative file f""" + if 'paths' in self.tables: + r = [x[0] for x in self.q("SELECT raw FROM paths WHERE rel = ?", f)] + if r: return r + return [f] + def rel(self, raw): + if self._raw is None: + self._raw = dict(self.q("SELECT raw, rel FROM paths")) if 'paths' in self.tables else {} + return self._raw.get(raw, raw) + def sites_on(self, f, line, text=None): + """the call sites starting on that line, each with `col`: the 1-based column of its callee's NAME as written (of + the call's start when it has none), the column an answer prints and `link` takes; s0/e0 its span on the line""" + raws = self.raw_paths(f); ph = ','.join('?' * len(raws)) + rows = self.q(f"SELECT id, caller_id, kind, callee_name, start_line, end_line, start_column, end_column FROM call_sites " + f"WHERE file_path IN ({ph}) AND start_line = ?", *raws, line) + out = [] + for sid, c, k, n, l, el, sc, ec in rows: + s0 = max(0, (sc or 0) - self.base) + e0 = ((ec or 0) - self.base) if el == l and ec is not None else len(text or '') + out.append(dict(id=sid, caller=c, kind=k, callee=n, line=l, end=el, s0=s0, e0=e0, col=name_col(text, n, s0, e0))) + return out + def reason(self, sid): + if 'ext_call_site_unresolved' not in self.tables: return '' + r = self.q("SELECT c2 FROM ext_call_site_unresolved WHERE c0 = ? LIMIT 1", sid) + return r[0][0] if r else '' + def unresolved(self, sid): + return bool('unresolved_sites' in self.tables and self.q("SELECT 1 FROM unresolved_sites WHERE call_site_id = ? LIMIT 1", sid)) + def declares(self, name): + """does a CALLABLE in this graph (a client or staged library method or function, or a type) carry this simple name? + A field, a variable or a parameter of that name holds a value, which is exactly what a link may name the target of""" + if self._names is None: + self._names = {r[0] for r in self.q("SELECT DISTINCT name FROM symbols WHERE name IS NOT NULL AND (method_id IS NOT NULL OR type_id IS NOT NULL)")} if 'symbols' in self.tables else set() + self._names |= {r[0] for r in self.q("SELECT DISTINCT name FROM methods")} + if 'types' in self.tables: + try: self._names |= {r[0] for r in self.q("SELECT DISTINCT name FROM types")} + except sqlite3.Error: pass + return name in self._names + def holds_value(self, name): + """is this name declared as a field / variable / constant (an attribute that can hold a callable)?""" + if getattr(self, '_fields', None) is None: + self._fields = {r[0] for r in self.q("SELECT DISTINCT name FROM symbols WHERE name IS NOT NULL AND method_id IS NULL AND type_id IS NULL")} if 'symbols' in self.tables else set() + if 'fields' in self.tables: + try: self._fields |= {r[0] for r in self.q("SELECT DISTINCT name FROM fields")} + except sqlite3.Error: pass + return name in self._fields + def display(self, sid): + r = self.q("SELECT display FROM symbols WHERE id = ? LIMIT 1", sid) + return r[0][0] if r else sid + + def targets(self, target, tfile=''): + """[(method id, display, file, provenance, simple name, kind, owner)] for a target as the graph names it""" + out = [] + hint = None + mh = re.fullmatch(r'(.+):(\d+)', tfile or '') + if mh: tfile, hint = mh.group(1), int(mh.group(2)) + m = re.fullmatch(r'(.+\.\w+):(\d+)', target) # a declaration by its file:line + if m: + rows = self.q("SELECT method_id, display, file, name, kind, owner, line FROM symbols WHERE file = ? AND line = ? AND method_id IS NOT NULL " + "AND kind <> 'module'", m.group(1), int(m.group(2))) + else: + rows = self.q("SELECT method_id, display, file, name, kind, owner, line FROM symbols WHERE (display = ? OR qualified_name = ?) " + "AND method_id IS NOT NULL AND kind <> 'module'", target, target) + rows = [r for r in rows if not tfile or r[2] == tfile] + # several declarations of one name in one file (overloads, an @overload stub beside its body): the one nearest the + # line the link recorded it at, which survives an edit that moves it a few lines + if hint is not None and len({r[0] for r in rows}) > 1: + rows = sorted(rows, key=lambda r: abs((r[6] or 0) - hint))[:1] + for mid, disp, f, name, kind, owner, _ln in rows: + out.append((mid, disp, f, 'client', name, kind, owner)) + if not out and not tfile: + for mid, qn, name, prov in self.q("SELECT id, qualified_name, name, provenance FROM methods WHERE qualified_name = ? AND provenance <> 'client'", target): + out.append((mid, qn, '', prov, name, 'method', qn.rsplit('.', 2)[-2] if qn.count('.') >= 1 else '')) + seen, uniq = set(), [] + for t in out: + if t[0] not in seen: seen.add(t[0]); uniq.append(t) + return uniq + + +def name_col(text, callee, s0, e0): + """1-based column of the callee's name inside the call's span on its line (the LAST occurrence: `h(x).run()` names run + after h), else of the span's start""" + nm = (callee or '').split('.')[-1].split('::')[-1] + if text and nm: + seg = text[s0:max(e0, s0)] if e0 and e0 > s0 else text[s0:] + k = -1 + for m in re.finditer(r'(?= 0: return s0 + k + 1 + return s0 + 1 + + +def stable_name(g, mid, display, tfile): + """the name a link records for its target: the display when it names one declaration in that file, else the qualified + name (a nested `decorator` is one of five in its file), else the display as it is. A line number would go stale with + the first edit above it, so it is never what is recorded.""" + if len(g.targets(display, tfile)) <= 1: return display, tfile + r = g.q("SELECT qualified_name FROM symbols WHERE method_id = ? AND qualified_name IS NOT NULL LIMIT 1", mid) + if r and r[0][0] and len(g.targets(r[0][0], tfile)) == 1: return r[0][0], tfile + # overloads share both names: the file carries the declaration's line as a hint (Graph.targets takes the nearest) + ln = g.q("SELECT line FROM symbols WHERE method_id = ? AND file = ? LIMIT 1", mid, tfile) + return display, (f"{tfile}:{ln[0][0]}" if ln else tfile) + + +def is_value_lib(g, sid): + """a site resolved only into a library method that runs a value (Method.invoke, Function.apply)""" + rows = g.q("SELECT e.tier, s.callee_name FROM call_edges e JOIN call_sites s ON s.id = e.call_site_id WHERE e.call_site_id = ?", sid) + return bool(rows) and all(t == 'boundary_lib' and (n or '').split('.')[-1] in REFLECTIVE_LIB for t, n in rows) + + +def consistent(g, site, tname, tkind, towner): + """'' when the call as written may be a call to the target, else why not""" + callee = (site['callee'] or '').split('.')[-1].split('::')[-1] + owner_simple = (towner or '').split('.')[-1] + if callee and callee == tname: return '' + if callee and (tname in CTOR_NAMES or tkind in ('constructor', 'class')) and callee in (owner_simple, tname): return '' + if site['kind'] in VALUE_KINDS: return '' + if callee in REFLECTIVE: return '' + rsn = g.reason(site['id']) + if any(rsn.startswith(v) for v in VALUE_REASONS): return '' + # a MEMBER call on a receiver the engine could not type (`x.index()`): the member's name is what it calls, most often + # a library method's; it is linked only to a declaration of that name + member = site['kind'] in MEMBER_KINDS or rsn.startswith('untyped_receiver') + if not callee or (not g.declares(callee) and (not member or g.holds_value(callee))): return '' # a local, a parameter, a field holding a value + if member and not g.declares(callee): + return (f"the call is to the member `{site['callee']}` of a receiver the graph could not type — most likely a library " + f"method of that name, not `{tname}`; a member call is linked only to a declaration of its own name, or where " + f"the member is a field holding a callable") + return (f"the call as written names `{site['callee']}`, which is a declaration in the graph and not `{tname}`; " + f"a link is taken where the call goes through a value (a parameter, a local, a table, getattr / reflection), " + f"or names the target itself") + + +def locate(g, reader, link): + """(line now, [sites on it], status reason). Follows the line by its text when lines above it moved.""" + L = reader.lines(link['file']) + if L is None: return None, [], 'stale: the file is gone' + want = link.get('line_sha') or '' + n = link['line'] + at = lambda i: g.sites_on(link['file'], i, L[i - 1] if 0 < i <= len(L) else '') + if 0 < n <= len(L) and (not want or line_sha(L[n - 1]) == want): + return n, at(n), '' + if not want: return None, [], 'stale: the line is past the end of the file' + cands = [i for i, t in enumerate(L, 1) if line_sha(t) == want] + callee = link.get('callee') or '' + with_site = [] + for i in cands: + # the same text in ANOTHER callable is another call: a moved line is followed only within the callable it was + # written in (`return fn(doc)` is a line many functions share) + ss = [s for s in at(i) if (not callee or s['callee'] == callee) + and (not link.get('caller') or g.display(s['caller']) == link['caller'])] + if ss: with_site.append((abs(i - n), i, ss)) + if not with_site: + return None, [], 'stale: the line was edited (no line with its text and that call is left in ' + (f"{link['caller']})" if link.get('caller') else 'the file)') + with_site.sort() + if len(with_site) > 1 and with_site[0][0] == with_site[1][0]: + return None, [], f"stale: the line's text now appears at lines {with_site[0][1]} and {with_site[1][1]}, equally near" + return with_site[0][1], with_site[0][2], '' + + +def resolve(g, reader, link): + """-> dict(status, reason, line, site, caller, target id, display, provenance)""" + r = dict(status='rejected', reason='', line=None, site=None, caller=None, callee_id=None, target_display=link['target'], prov='client') + ts = g.targets(link['target'], link.get('target_file') or '') + if not ts: + r['reason'] = ('the target is not a declaration in this graph' + (f" in {link['target_file']}" if link.get('target_file') else '') + + ' (renamed, deleted or moved?)'); r['status'] = 'stale' if link.get('target_file') else 'rejected' + return r + if len(ts) > 1: + r['reason'] = f"the target names {len(ts)} declarations ({', '.join(sorted({t[2] or '' for t in ts})[:4])}): name it as file:line"; return r + mid, disp, tf, prov, tname, tkind, towner = ts[0] + r.update(callee_id=mid, target_display=disp, prov=prov, target_file=tf) + line, sites, why = locate(g, reader, link) + if why: r.update(status='stale', reason=why); return r + r['line'] = line + if not sites: + r['reason'] = f"no call is written at {link['file']}:{line}"; return r + callee = link.get('callee') or '' + if callee: sites = [s for s in sites if s['callee'] == callee] or sites + # A COLUMN NAMES ONE CALL: the call whose name starts there, read through the line's normalized text so a re-indented + # or re-spaced line keeps it. A column that names no call on the line is stale, never a nearby guess. + if link.get('col') or link.get('ncol') is not None: + L = reader.lines(link['file']) or [] + text = L[line - 1] if 0 < line <= len(L) else '' + col = denorm_col(text, link['ncol']) if link.get('ncol') is not None else link['col'] + at_line = sites + sites = [s for s in sites if s['col'] == col] + if not sites: + have = ', '.join(str(x['col']) for x in sorted(at_line, key=lambda x: x['col'])) + r.update(status='stale', reason=f"stale: no call's name starts at column {col} of {link['file']}:{line} (calls there: column {have})"); return r + # the engine already made this very edge: nothing to add, whatever the call as written says + for s_ in sites: + if g.q("SELECT 1 FROM call_edges WHERE call_site_id = ? AND callee_method_id = ? AND tier <> ? LIMIT 1", s_['id'], mid, TIER): + r.update(status='redundant', reason='the graph already has this edge', site=s_['id'], caller=s_['caller'], kind=s_['kind'], + callee_written=s_['callee']) + return r + ok = [(s, consistent(g, s, tname, tkind, towner)) for s in sites] + good = [s for s, w in ok if not w] + if not good: + r['reason'] = ok[0][1]; return r + if len(good) > 1: + # the one call named like the target; else the calls the graph could not follow (an unresolved site, or one into a + # library method that runs a value) over the ones it resolved + un = [s for s in good if g.unresolved(s['id']) or is_value_lib(g, s['id'])] + named = [s for s in good if (s['callee'] or '').split('.')[-1] == tname] + good = named if len(named) == 1 else un if un else good + if len(good) > 1: + # NEVER CHOOSE between two calls the link could mean (`a.run() + b.run()`, `f(a)(b)`): the column says which + which = ', '.join(f"`{x['callee'] or '?'}` at column {x['col']}" for x in sorted(good, key=lambda x: x['col'])) + r['reason'] = (f"{len(good)} calls on that line could be it ({which}): " + f"give the column, `axiomcode link {link['file']}:{line}: {link['target']}`"); return r + s = good[0] + r.update(site=s['id'], caller=s['caller'], kind=s['kind'], callee_written=s['callee'], col=s['col'], s0=s['s0'], e0=s['e0']) + if g.q("SELECT 1 FROM call_edges WHERE call_site_id = ? AND callee_method_id = ? AND tier <> ? LIMIT 1", s['id'], mid, TIER): + r.update(status='redundant', reason='the graph already has this edge'); return r + r['status'] = 'applied' if line == link['line'] else 'moved' + if r['status'] == 'moved': r['reason'] = f"followed by its text from line {link['line']} to {line}" + return r + + + +# ── what a link makes resolvable AFTER it: calls on the linked call's result ──────────────────────────────────── +# A link names a declaration, so the type its call returns is known: a call chained on the result (`h(req).render()`) +# or made on a local assigned from it (`x = h(req)` … `x.render()`) resolves to that type's member, at apply time and with +# no re-solve. Each such edge is `asserted` too, remembers the link it came from, and goes when that link goes. +WRAPPERS = {'Optional', 'Promise', 'PromiseLike', 'Task', 'ValueTask', 'Awaitable', 'Coroutine', 'Future', 'CompletableFuture', + 'Final', 'Annotated', 'Readonly', 'Mono', 'Deferred'} +ELEMENT_OF = {'list', 'List', 'Iterable', 'Iterator', 'Sequence', 'Collection', 'Set', 'set', 'tuple', 'Tuple', 'Array', 'ReadonlyArray', + 'IEnumerable', 'IList', 'ICollection', 'IReadOnlyList', 'IReadOnlyCollection', 'Stream', 'Generator', 'AsyncIterable', + 'AsyncIterator', 'IAsyncEnumerable', 'ArrayList', 'LinkedList', 'HashSet', 'frozenset'} +DECL_WORDS = r'(?:const|let|var|val|final|auto|readonly)' + + +def split_generic(t): + """`Optional[Response]` / `Task>` -> ('Optional', ['Response']) ; `Response` -> ('Response', [])""" + t = (t or '').strip().strip('"\'').strip() + m = re.match(r'^([\w.$]+)\s*[\[<](.*)[\]>]\s*$', t) + if not m: return t, [] + args, depth, cur = [], 0, '' + for ch in m.group(2): + if ch in '[<(': depth += 1 + elif ch in ']>)': depth -= 1 + if ch == ',' and depth == 0: args.append(cur.strip()); cur = '' + else: cur += ch + if cur.strip(): args.append(cur.strip()) + return m.group(1), args + + +def clean_type(t, owner=''): + """an annotation as written -> the simple name of the type a call of it returns (wrappers, Optional and `| None` off)""" + t = (t or '').strip().strip('"\'').strip().rstrip('?!').strip() + if not t: return '' + parts = [x.strip() for x in re.split(r'\|', t) if x.strip() not in ('None', 'null', 'undefined', 'void')] if '|' in t and '<' not in t and '[' not in t else [t] + if len(parts) != 1: return '' + t = parts[0] + head, args = split_generic(t) + last = head.split('.')[-1] + if last in WRAPPERS and args: return clean_type(args[0], owner) + if last in ('Self', 'this') and owner: return owner.split('.')[-1] + return last + + +def element_type(t): + head, args = split_generic((t or '').strip().strip('"\'')) + if head.split('.')[-1] in WRAPPERS and args: return element_type(args[0]) + if head.split('.')[-1] in ELEMENT_OF and args: return clean_type(args[0]) + m = re.match(r'^(.+)\[\]$', (t or '').strip()) # T[] + return clean_type(m.group(1)) if m else '' + + +class Types: + def __init__(self, g, reader): + self.g, self.reader, self.memo = g, reader, {} + def type_named(self, name): + if not name: return None + rows = {(t, d) for t, d in self.g.q("SELECT type_id, display FROM symbols WHERE type_id IS NOT NULL AND method_id IS NULL AND (name = ? OR display = ?)", name, name)} + return sorted(rows)[0] if len(rows) == 1 else None + def owner_type(self, mid): + r = self.g.q("SELECT owner_type_id FROM methods WHERE id = ?", mid) + if r and r[0][0]: + d = self.g.q("SELECT display FROM symbols WHERE type_id = ? AND method_id IS NULL LIMIT 1", r[0][0]) + return (r[0][0], d[0][0] if d else r[0][0]) + return None + def declared(self, mid): + """the return type as WRITTEN: (text, how) from the graph's type_use, else the declaration's own header""" + g = self.g + if 'type_use' in g.tables: + rows = g.q("SELECT tu.depth, COALESCE(sy.display, ty.name) FROM type_use tu LEFT JOIN symbols sy ON sy.type_id = tu.type_id AND sy.method_id IS NULL " + "LEFT JOIN types ty ON ty.id = tu.type_id WHERE tu.owner_method_id = ? AND tu.context = 'METHOD_RETURN' ORDER BY tu.depth", mid) if 'types' in g.tables else [] + names = [n for _d, n in rows if n] + if names: return (names[0] + (f"<{', '.join(names[1:])}>" if len(names) > 1 else ''), 'declared') + sy = g.q("SELECT name, file, line, end_line FROM symbols WHERE method_id = ? AND file IS NOT NULL LIMIT 1", mid) + if not sy: return None + name, f, ln, end = sy[0] + L = self.reader.lines(f) or [] + # the header only: up to the line that opens the body (a Python `:` at the end of a line, else `{` / `=>` / `;`) + hl = [] + for t in L[ln - 1: min(len(L), ln + 11)]: + hl.append(t) + if (g.lang == 'python' and re.search(r':\s*(#.*)?$', t)) or (g.lang != 'python' and re.search(r'[{;]|=>', t)): break + head = ' '.join(hl) + lang = g.lang + if lang == 'python': + m = re.search(r'\bdef\s+' + re.escape(name) + r'\s*\(.*?\)\s*->\s*(.+?)\s*:(?:\s|$)', head) + if m: return (m.group(1), 'annotation') + elif lang == 'typescript': + m = re.search(re.escape(name) + r'\s*(?:<[^>]*>)?\s*\((?:[^()]|\([^()]*\))*\)\s*:\s*([^={;]+?)\s*(?:\{|=>|;|$)', head) + if m: return (m.group(1), 'declared') + elif lang in ('java', 'csharp'): + m = re.search(r'([\w.$]+(?:\s*<[^()]*?>)?(?:\[\])?\??)\s+' + re.escape(name) + r'\s*(?:<[^>]*>)?\s*\(', head) + if m and m.group(1) not in ('new', 'return', 'void', 'else'): return (m.group(1), 'declared') + if lang in ('javascript', 'typescript'): + for k in range(max(0, ln - 16), ln - 1): + m = re.search(r'@returns?\s*\{([^}]+)\}', L[k]) + if m: return (m.group(1), 'JSDoc') + return None + def inferred(self, mid): + """the type every `return` of the body names: `self` / `this` (the owner), `new T(…)` / `T(…)` of a type in the graph""" + sy = self.g.q("SELECT file, line, end_line FROM symbols WHERE method_id = ? AND file IS NOT NULL LIMIT 1", mid) + if not sy: return None + f, ln, end = sy[0] + L = self.reader.lines(f) or [] + got = set() + for t in L[ln: (end or ln)]: + m = re.search(r'\breturn\s+(?:await\s+)?(.+?)\s*;?\s*$', t) + if not m: continue + v = m.group(1) + if re.fullmatch(r'(self|this)', v): o = self.owner_type(mid); got.add(o[1].split('.')[-1] if o else '?'); continue + m2 = re.match(r'(?:new\s+)?([A-Za-z_$][\w$.]*)\s*(?:<[^>]*>)?\s*\(', v) + if m2 and self.type_named(m2.group(1).split('.')[-1]): got.add(m2.group(1).split('.')[-1]); continue + got.add('?') + return (got.pop(), 'inferred from its returns') if len(got) == 1 and '?' not in got else None + def returns(self, mid): + """-> (type_id, display, how, element type text or '') for what a call of mid returns, or None""" + if mid in self.memo: return self.memo[mid] + g = self.g; out = None + sy = g.q("SELECT name, kind FROM symbols WHERE method_id = ? LIMIT 1", mid) + nm, kind = sy[0] if sy else ('', '') + if nm in CTOR_NAMES or kind == 'constructor': + o = self.owner_type(mid) + if o: out = (o[0], o[1], 'constructs it', '') + if out is None: + d = self.declared(mid) + o = self.owner_type(mid) + if d: + t = clean_type(d[0], o[1] if o else '') + tt = self.type_named(t) + if tt: out = (tt[0], tt[1], d[1], '') + else: + el = element_type(d[0]) + if el and self.type_named(el): out = (None, d[0], d[1], el) + if out is None: + i = self.inferred(mid) + tt = self.type_named(i[0]) if i else None + if tt: out = (tt[0], tt[1], i[1], '') + self.memo[mid] = out + return out + def member(self, type_id, name): + """(method id, display) of the member `name` on the type or the nearest base declaring it""" + g = self.g; seen = []; todo = [type_id] + while todo and len(seen) < 30: + t = todo.pop(0) + if t in seen: continue + seen.append(t) + r = g.q("SELECT m.id, COALESCE(sy.display, m.qualified_name) FROM methods m LEFT JOIN symbols sy ON sy.method_id = m.id " + "WHERE m.owner_type_id = ? AND m.name = ? LIMIT 1", t, name) + if r: return r[0] + if 'type_ancestors' in g.tables: + todo += [a for (a,) in g.q("SELECT ancestor_type_id FROM type_ancestors WHERE type_id = ?", t)] + return None + + +def derive(g, reader, res, cap=60): + """-> ([(site id, caller id, method id, label, how)], note) for the calls on the linked call's result""" + T = Types(g, reader) + rt = T.returns(res['callee_id']) + if not rt: return [], 'return type unknown, calls on its result stay unknown' + f, line = res['file'], res['line'] + L = reader.lines(f) or [] + text = L[line - 1] if 0 < line <= len(L) else '' + out = [] + def site_at(ln, name, col0): + for s in g.sites_on(f, ln, L[ln - 1] if 0 < ln <= len(L) else ''): + if (s['callee'] or '').split('.')[-1] == name and s['col'] == col0 + 1: return s + return None + def chain(ln, pos, t, how): + """walk `.m()` / `.attr` segments after column pos (0-based) of line ln, starting from type t""" + seg_text = L[ln - 1] if 0 < ln <= len(L) else '' + depth = 0 + while t and t[0] and depth < 8 and len(out) < cap: + m = re.match(r'\s*(?:\)\s*)*(?:!\s*)?\??\.\s*([A-Za-z_$][\w$]*)\s*(?:<[^<>()]*>)?\s*(\()?', seg_text[pos:]) + if not m: return + name, is_call = m.group(1), bool(m.group(2)) + mem = T.member(t[0], name) + if not mem: return + name0 = pos + m.start(1) + if is_call: + s = site_at(ln, name, name0) + if not s: return + if not g.q("SELECT 1 FROM call_edges WHERE call_site_id = ? AND callee_method_id = ? AND tier <> ?", s['id'], mem[0], TIER): + out.append((s['id'], s['caller'], mem[0], mem[1], f"{how}: on the result, {t[1]}", s['kind'])) + pos = s['e0'] + else: + s = site_at(ln, name, name0) # a property read the parser recorded as a call + if s and not g.q("SELECT 1 FROM call_edges WHERE call_site_id = ? AND callee_method_id = ? AND tier <> ?", s['id'], mem[0], TIER): + out.append((s['id'], s['caller'], mem[0], mem[1], f"{how}: a property of the result, {t[1]}", s['kind'])) + pos = name0 + len(name) + t = T.returns(mem[0]); how = 'derived'; depth += 1 + # (1) chained on the same expression + chain(line, res['e0'], rt, 'derived') + # (2) a local assigned from it, or an element of it + before = text[:res['s0']] + ma = (re.search(r'(?:^|[\s(,;])(?:' + DECL_WORDS + r'\s+|[\w.$<>\[\],?]+\s+)?([A-Za-z_$][\w$]*)\s*(?::[^=]+)?(?])=(?!=)\s*(?:await\s+)?\(?\s*$', before) + or re.search(r'\(\s*([A-Za-z_]\w*)\s*:=\s*(?:await\s+)?$', before)) + mw = re.match(r'\s*(?:\)\s*)?as\s+([A-Za-z_]\w*)', text[res['e0']:]) if g.lang == 'python' else None + mf = re.search(r'\bfor(?:each)?\s*\(?\s*(?:' + DECL_WORDS + r'\s+|[\w<>?]+\s+)?([A-Za-z_$][\w$]*)\s+(?:of|in|:)\s*(?:await\s+)?$', before) + local, lt = None, None + if mf and rt[3]: + el = T.type_named(rt[3]); local, lt = mf.group(1), ((el[0], el[1], 'derived', '') if el else None) + elif ma or mw: + local = (ma or mw).group(1); lt = rt + if mw: + ent = T.member(rt[0], '__enter__') + if ent: lt = T.returns(ent[0]) or rt + if local and lt and lt[0]: + cs = g.q("SELECT file, line, end_line FROM symbols WHERE id = ? LIMIT 1", res['caller']) + lo, hi = (cs[0][1], cs[0][2]) if cs and cs[0][2] else (line, min(len(L), line + 200)) + body = L[lo - 1: hi] + assign = re.compile(r'(?:^|[^\w$.])' + re.escape(local) + r'\s*(?::[^=()]+)?(?])=(?!=)|\bfor\s*\(?\s*(?:\w+\s+)?' + re.escape(local) + + r'\s+(?:in|of)\b|\bas\s+' + re.escape(local) + r'\b|\(\s*' + re.escape(local) + r'\s*:=') + if sum(1 for t in body if assign.search(t)) > 1: + return out, f"`{local}` is assigned more than once in {g.display(res['caller'])}: calls on it stay unknown" + use = re.compile(r'(? [(link, result)]""" + if links is None: links, bad = read_links(repo) + if sha is None: sha = file_sha(repo) + con = sqlite3.connect(db, timeout=30) + try: + g = Graph(con) + if 'call_sites' not in g.tables or 'call_edges' not in g.tables: return [] + had = bool(con.execute("SELECT 1 FROM call_edges WHERE tier = ? LIMIT 1", (TIER,)).fetchone()) or 'asserted_links' in g.tables + if not links and not bad and not had: return [] + res = [(l, resolve(g, reader, l)) for l in links] + # what each applied link makes resolvable after it, read before any row is written (the engine's own edges decide) + derived = {} + for l, r in res: + if r['status'] in ('applied', 'moved') and r.get('e0') is not None: + try: derived[l['n']] = derive(g, reader, dict(r, file=l['file'])) + except Exception as e: derived[l['n']] = ([], f'nothing derived ({type(e).__name__})') + con.execute("DELETE FROM call_edges WHERE tier = ?", (TIER,)) + con.execute(LINK_TABLE); con.execute("DELETE FROM asserted_links") + con.execute(DERIVED_TABLE); con.execute("DELETE FROM asserted_derived") + done = set() + ins = lambda sid, c, m, lab, prov, k: con.execute( + "INSERT INTO call_edges(call_site_id, caller_id, callee_method_id, callee_label, callee_provenance, tier, kind) VALUES (?,?,?,?,?,?,?)", + (sid, c, m, lab, prov, TIER, k or 'call')) + for l, r in res: + if r['status'] in ('applied', 'moved') and (r['site'], r['callee_id']) not in done: + done.add((r['site'], r['callee_id'])) + ins(r['site'], r['caller'], r['callee_id'], r['target_display'], r['prov'], r.get('kind')) + if l['n'] in derived: + edges, why = derived[l['n']] + for sid, c, m, lab, how, k in edges: + con.execute("INSERT INTO asserted_derived VALUES (?,?,?,?,?)", (l['n'], sid, m, lab, how)) + if (sid, m) not in done: done.add((sid, m)); ins(sid, c, m, f"{lab} (derived from link {l['n']})", 'client', k) + r['reason'] = ('; '.join(x for x in (r['reason'], why) if x)) + con.execute("INSERT INTO asserted_links VALUES (?,?,?,?,?,?,?,?,?,?)", + (l['n'], l['file'], l['line'], r['line'], l['target'], r['callee_id'], r['site'], r['caller'], r['status'], r['reason'])) + for n, raw, why in bad or []: + con.execute("INSERT INTO asserted_links VALUES (?,?,?,?,?,?,?,?,?,?)", (n, '', None, None, raw[:120], None, None, None, 'malformed', why)) + if 'index_meta' in g.tables: + con.execute("DELETE FROM index_meta WHERE key = 'links_sha'"); con.execute("INSERT INTO index_meta VALUES ('links_sha', ?)", (sha,)) + con.commit() + return res + finally: + con.close() + + +# ── the derived facts, patched rather than re-exported ──────────────────────────────────────────────────────── +def _rewrite(path, keep, add): + try: + with open(path, encoding='utf-8') as fh: rows = [l for l in fh.read().split('\n') if l and keep(l.split('\t'))] + except OSError: return False + tmp = f"{path}.{os.getpid()}.tmp" + with open(tmp, 'w', encoding='utf-8') as fh: + for l in rows: fh.write(l + '\n') + for r in add: fh.write('\t'.join(str(x) for x in r) + '\n') + os.replace(tmp, path); return True + + +def patch_facts(db, old_mtime, rows): + """the path and impact facts exported for this graph are keyed on its mtime (axiomcode-path / axiomcode-impact + export()). Writing the asserted rows moved the mtime, which would make the next query re-export every relation + (minutes on a large graph). The asserted rows reach those facts in exactly three relations — path's edge, impact's + calls and cert_tier — so those are rewritten (rows of tier `asserted` dropped, the new ones added) and the stamps + moved to the new mtime. A facts directory stamped for some other graph is left alone: it re-exports as before.""" + try: new = os.stat(db).st_mtime + except OSError: return + gdir = os.path.dirname(os.path.dirname(db)) if os.path.basename(os.path.dirname(db)) == 'out' else os.path.dirname(db) + facts = os.path.join(gdir, 'out', 'dl') + import ax_edges + for stamp, files in ((os.path.join(facts, 'stamp'), 'path'), (os.path.join(facts, 'impact', 'stamp'), 'impact')): + try: cur = open(stamp).read() + except OSError: continue + if not cur.startswith(f"{old_mtime}:"): continue + ok = True + if files == 'path': + ok = _rewrite(os.path.join(facts, 'edge.facts'), lambda p: len(p) < 3 or p[2] != TIER, + sorted({(c, m, TIER) for c, m, prov, _f, _l in rows if prov in ('client', 'generated')})) + else: + D = os.path.join(facts, 'impact') + ok = _rewrite(os.path.join(D, 'calls.facts'), lambda p: len(p) < 3 or p[2] != TIER, + sorted({(c, m, TIER, f, l) for c, m, prov, f, l in rows if prov == 'client'})) + ok = ok and _rewrite(os.path.join(D, 'cert_tier.facts'), lambda p: p[0] != TIER, + [(TIER, ax_edges.direct_cert(TIER), ax_edges.direct_why(TIER))]) + if ok: + tmp = f"{stamp}.{os.getpid()}.tmp"; open(tmp, 'w').write(f"{new}:{cur.split(':', 1)[1]}"); os.replace(tmp, stamp) + + +def asserted_rows(db): + con = sqlite3.connect(f"file:{db}?mode=ro", uri=True) + try: + g = Graph(con) + return [(c, m, prov, g.rel(f) if f else '', l or 0) for c, m, prov, f, l in con.execute( + "SELECT e.caller_id, e.callee_method_id, e.callee_provenance, s.file_path, s.start_line FROM call_edges e " + "LEFT JOIN call_sites s ON s.id = e.call_site_id WHERE e.tier = ?", (TIER,))] + finally: + con.close() + + +def apply_repo(repo, quiet=True): + """apply the links file to every graph of the repository, patching each one's facts. -> {graph: results}""" + links, bad = read_links(repo); sha = file_sha(repo); out = {} + for db, reader, live in graph_dbs(repo): + try: old = os.stat(db).st_mtime + except OSError: continue + try: res = apply_db(db, repo, reader, links, bad, sha) + except sqlite3.Error as e: + if not quiet: print(f"axiomcode link: {db}: {e}", file=sys.stderr) + continue + if res or links or bad: patch_facts(db, old, asserted_rows(db)) + out[db] = (live, res) + return out + + +def sync(repo): + """before a query: re-apply when the links file differs from what the graphs were last given (edited by hand, + pulled, removed). One read of a small file and one row per graph; nothing at all with no links file and no links.""" + sha = file_sha(repo) + for db, _r, _live in graph_dbs(repo): + try: + con = sqlite3.connect(f"file:{db}?mode=ro", uri=True) + try: got = con.execute("SELECT value FROM index_meta WHERE key = 'links_sha'").fetchone() + except sqlite3.Error: got = None + con.close() + except sqlite3.Error: continue + if (got[0] if got else '') != sha: + apply_repo(repo); return True + return False + + +# ── what a query says about them ────────────────────────────────────────────────────────────────────────────── +def status_rows(q): + try: return q("SELECT n, file, line, at_line, target, status, reason FROM asserted_links ORDER BY n") + except sqlite3.Error: return [] + + +def note(q): + """one line for an answer when some link was not applied, else ''""" + rows = status_rows(q) + off = [r for r in rows if r[5] in ('stale', 'rejected', 'malformed')] + if not off: return '' + by = {} + for r in off: by[r[5]] = by.get(r[5], 0) + 1 + return (f"links: {len(off)} of {len(rows)} asserted link(s) not applied ({', '.join(f'{n} {s}' for s, n in sorted(by.items()))})" + f" — `axiomcode link` lists them") + + +def unknown_sites(q, callers, site_file, limit=30, order=None, repo=None): + """the unresolved call sites inside these callables: where the answer stops being complete. Each with the call as + written, the engine's own reason, and the targets it was linked to (if any).""" + callers = [c for c in dict.fromkeys(callers) if c] + if not callers: return [], 0 + tabs = {r[0] for r in q("SELECT name FROM sqlite_master WHERE type IN ('table','view')")} + if 'unresolved_sites' not in tabs: return [], 0 + rows = []; libcall = {} + for i in range(0, len(callers), 500): + ch = callers[i:i + 500]; ph = ','.join('?' * len(ch)) + rows += [tuple(r) for r in q(f"SELECT s.id, s.caller_id, s.kind, s.callee_name, s.file_path, s.start_line FROM unresolved_sites u " + f"JOIN call_sites s ON s.id = u.call_site_id WHERE u.caller_id IN ({ph})", *ch)] + # A CALL RESOLVED INTO A LIBRARY THAT RUNS A VALUE: Method.invoke, a delegate's Invoke, Function.apply. The engine + # resolved it (to the library method), so it is no unresolved site, and what it runs is as unknown as one's + for sid, c, k, n, f, l, lib in q(f"SELECT s.id, s.caller_id, s.kind, s.callee_name, s.file_path, s.start_line, e.callee_label FROM call_edges e " + f"JOIN call_sites s ON s.id = e.call_site_id WHERE e.tier = 'boundary_lib' AND e.caller_id IN ({ph})", *ch): + if (n or '').split('.')[-1] in REFLECTIVE_LIB: rows.append((sid, c, k, n, f, l)); libcall[sid] = lib + # …and a typed front end's call through a holder of a FUNCTION TYPE: resolved to a signature with no body, so the + # function the holder is given is what runs (axiomcode-path's value calls, FUNCTION_TYPE_KINDS there) + for sid, c, k, n, f, l, qn in q(f"SELECT s.id, s.caller_id, s.kind, s.callee_name, s.file_path, s.start_line, m.qualified_name FROM call_edges e " + f"JOIN call_sites s ON s.id = e.call_site_id JOIN methods m ON m.id = e.callee_method_id " + f"WHERE m.kind IN ('FUNCTION_TYPE_SIGNATURE', 'CALL_SIGNATURE', 'TYPE_LITERAL_CALL_SIGNATURE') AND e.caller_id IN ({ph})", *ch): + rows.append((sid, c, k, n, f, l)); libcall[sid] = 'a function-type signature, not a body' + rows = list(dict.fromkeys(rows)) + total = len(rows) + rank = {c: i for i, c in enumerate(order or callers)} + rows.sort(key=lambda r: (rank.get(r[1], len(rank)), str(r[4]), r[5] or 0)) + reasons, linked = {}, {} + ids = [r[0] for r in rows[:limit]] + if ids: + ph = ','.join('?' * len(ids)) + if 'ext_call_site_unresolved' in tabs: + for sid, why in q(f"SELECT c0, c2 FROM ext_call_site_unresolved WHERE c0 IN ({ph})", *ids): reasons.setdefault(sid, why) + for sid, lab in q(f"SELECT call_site_id, callee_label FROM call_edges WHERE tier = 'asserted' AND call_site_id IN ({ph})", *ids): + linked.setdefault(sid, []).append(lab) + # the column of each call's name, so a site is written file:line:col and two calls on one line are told apart + base = 0 + try: + lg = q("SELECT value FROM run WHERE key = 'language'") + base = 1 if lg and lg[0][0] in ('typescript', 'javascript') else 0 + except sqlite3.Error: pass + spans = {r[0]: (r[1], r[2], r[3], r[4]) for r in (q(f"SELECT id, start_line, start_column, end_line, end_column FROM call_sites WHERE id IN ({ph})", *ids) if ids else [])} + rd = Reader(repo) if repo else None + def col_of(sid, f, callee): + sl, sc, el, ec = spans.get(sid, (0, 0, 0, None)) + L = rd.lines(f) if rd and f else None + text = L[sl - 1] if L and 0 < (sl or 0) <= len(L) else '' + s0 = max(0, (sc or 0) - base); e0 = ((ec or 0) - base) if el == sl and ec is not None else len(text) + return name_col(text, callee, s0, e0) + disp = {} + out = [] + for sid, caller, kind, callee, f, ln in rows[:limit]: + if caller not in disp: + d = q("SELECT display FROM symbols WHERE id = ? LIMIT 1", caller); disp[caller] = d[0][0] if d else caller + lc = str(libcall.get(sid, '')) + why = reasons.get(sid) or ((f"calls a value typed by {lc}" if ' ' in lc else f"runs a value through {lc.split(':')[-1]}") if sid in libcall else 'unresolved') + rf = site_file(f) if f else '?' + out.append(dict(at=f"{rf}:{ln or 0}", site=f"{rf}:{ln or 0}:{col_of(sid, rf, callee)}", call=callee or '', kind=kind, caller=disp[caller], + reason=why, linked=sorted(linked.get(sid, [])))) + return out, total + + +def code_at(repo, at): + f, _, n = at.rpartition(':') + try: + with open(os.path.join(repo, f), encoding='utf-8', errors='replace') as fh: L = fh.read().split('\n') + return L[int(n) - 1].strip() if 0 < int(n) <= len(L) else '' + except (OSError, ValueError): return '' + + +def unknown_lines(repo, sites, total, shown=None): + """the `unknown:` block of an answer""" + if not sites: return [] + shown = sites[:shown] if shown else sites + out = [f"unknown: {total} unresolved call site(s) inside — what each reaches is not in this answer" + + (f" (first {len(shown)})" if total > len(shown) else '') + ':'] + for s in shown: + code = code_at(repo, s['at']) + out.append(f" {s.get('site') or s['at']} {code[:90]} [{s['reason']}]" + (f" → linked: {', '.join(s['linked'])} [asserted]" if s['linked'] else '')) + out.append(" if the code shows where one of these lands and the task depends on it: `axiomcode link `") + return out + + +# ── the verb ────────────────────────────────────────────────────────────────────────────────────────────────── +USAGE = """axiomcode link [] record that the call written there reaches +axiomcode link list every link and whether the graph took it +axiomcode link - remove the links at that site (--remove []) + + is a declaration as the graph names it (Owner.method, function) or its file:line. Only a call the graph could +not resolve the same way is worth a link, and only when the code makes the target CERTAIN: an asserted edge is walked by +impact, path and tests like any other, labelled [asserted].""" + + +def find_repo(args): + for a in reversed(args): + if os.path.isdir(a) and not re.search(r':\d+$', a): return os.path.realpath(a), [x for x in args if x is not a] + return os.path.realpath(os.getcwd()), args + + +def site_arg(repo, s): + """file:line or file:line:col -> (file, line, col or None)""" + m = re.fullmatch(r'(.+?):(\d+)(?::(\d+))?', s or '') + if not m: return None, None, None + f = m.group(1) + if os.path.isabs(f): f = os.path.relpath(os.path.realpath(f), repo) + f = f[2:] if f.startswith('./') else f + return f.replace(os.sep, '/'), int(m.group(2)), (int(m.group(3)) if m.group(3) else None) + + +def list_links(repo, as_json=False): + links, bad = read_links(repo) + status = {} + for db, _r, live in graph_dbs(repo): + if not live: continue + try: + con = sqlite3.connect(f"file:{db}?mode=ro", uri=True) + for n, f, l, at, t, st, why in status_rows(lambda s, *a: con.execute(s, a).fetchall()): + prev = status.get(n) + # a link lives in one language's graph: the best verdict any graph gave it + if prev is None or RANK.get(st, 9) < RANK.get(prev[0], 9): status[n] = (st, why, at) + con.close() + except sqlite3.Error: pass + # what the graph holds may predate an edit: the line's text is checked against the file now as well + rows = [] + for l in links: + st, why, at = status.get(l['n'], ('not applied', 'the graph has not been given this link yet', None)) + L = Reader(repo).lines(l['file']) + same = lambda k: bool(k) and 0 < k <= len(L) and line_sha(L[k - 1]) == l.get('line_sha') + if L is None: st, why = 'stale', 'the file is gone' + elif st in ('applied', 'moved', 'redundant') and l.get('line_sha') and not same(at or l['line']): + st, why = 'changed', 'the line was edited since the graph was built; the next refresh re-validates it (followed if it only moved)' + rows.append(dict(link=l['n'], site=f"{l['file']}:{l['line']}" + (f":{l['col']}" if l.get('col') else ''), now=at, callee=l.get('callee'), target=l['target'], + target_file=l.get('target_file'), status=st, reason=why, by=l.get('by'), at=l.get('at'))) + for n, raw, why in bad: + rows.append(dict(link=n, site='', target=raw[:80], status='malformed', reason=why)) + rows.sort(key=lambda r: r['link']) + if as_json: + print(json.dumps(dict(file=links_path(repo), links=rows), indent=1)); return 0 + if not rows: + print(f"no asserted links ({links_path(repo)} has none)"); return 0 + print(f"{len(rows)} asserted link(s) in {os.path.relpath(links_path(repo), repo)}:") + for r in rows: + where = r['site'] + (f" (now line {r['now']})" if r.get('now') and r['site'] and int(r['site'].split(':')[1]) != r['now'] else '') + print(f" {r['link']:>3}. [{r['status']}] {where} → {r['target']}" + (f" — {r['reason']}" if r['reason'] else '')) + return 0 + + +def main(argv): + as_json = '--json' in argv + argv = [a for a in argv if a != '--json'] + remove = '--remove' in argv + argv = [a for a in argv if a != '--remove'] + if '--list' in argv: argv = [a for a in argv if a != '--list']; return list_links(find_repo(argv)[0], as_json) + if argv and argv[0] in ('-h', '--help', 'help'): print(USAGE); return 0 + repo, args = find_repo(argv) + if not os.path.isdir(os.path.join(repo, '.axiomcode')): + print(f"axiomcode link: no graph for {repo} — ask a question first (impact, path) so it is built", file=sys.stderr); return 2 + if not args: return list_links(repo, as_json) + f, line, col = site_arg(repo, args[0]) + if f is None: + print(f"axiomcode link: the site is written file:line (as an answer's `unknown:` block prints it), not {args[0]!r}\n\n{USAGE}", file=sys.stderr); return 2 + links, bad = read_links(repo) + if remove or (len(args) > 1 and args[1] == '-'): + tgt = args[1] if len(args) > 1 and args[1] != '-' else None + keep = [l for l in links if not (l['file'] == f and l['line'] == line and (tgt is None or l['target'] == tgt) + and (col is None or l.get('col') in (None, col)))] + gone = len(links) - len(keep) + write_links(repo, keep + []) # malformed lines are dropped only by hand; they stay listed until fixed + _restore_bad(repo, bad) + apply_repo(repo) + print(f"removed {gone} link(s) at {f}:{line}" if gone else f"no link at {f}:{line}") + return 0 if gone else 1 + if len(args) < 2: + print(USAGE, file=sys.stderr); return 2 + target = args[1]; callee = args[2] if len(args) > 2 else '' + reader = Reader(repo); L = reader.lines(f) + if L is None or not (0 < line <= len(L)): + print(f"axiomcode link: rejected — {f}:{line} is not a line of a file in this repository", file=sys.stderr); return 1 + new = dict(file=f, line=line, line_sha=line_sha(L[line - 1]), callee=callee, caller='', target=target, target_file='', + col=col, ncol=norm_col(L[line - 1], col) if col else None, + by=os.environ.get('AXIOMCODE_LINK_BY') or os.environ.get('USER') or 'agent', at=time.strftime('%Y-%m-%dT%H:%M:%S'), n=0) + # validate against the live graphs first: a link no graph would take is refused, not written + verdicts = [] + for db, rd, live in graph_dbs(repo): + if not live: continue + con = sqlite3.connect(f"file:{db}?mode=ro", uri=True) + try: g = Graph(con); verdicts.append((resolve(g, rd, new), g)) + except sqlite3.Error: pass + finally: pass + good = [(r, g) for r, g in verdicts if r['status'] in ('applied', 'redundant')] + if not good: + why = sorted(verdicts, key=lambda x: (x[0]['reason'].startswith('no call is written'), x[0]['reason'].startswith('the target is not'))) + print(f"axiomcode link: rejected — {why[0][0]['reason'] if why else 'no graph to check it against'}", file=sys.stderr); return 1 + r, g = good[0] + new.update(callee=r.get('callee_written') or callee, caller=g.display(r['caller']) if r.get('caller') else '', + target=r['target_display'], target_file=r.get('target_file') or '') + new['target'], new['target_file'] = stable_name(g, r['callee_id'], r['target_display'], new['target_file']) + g.con.close() + # the column the link resolved to is recorded even when none was given: it is what the link names from now on + if r.get('col') and not new.get('col'): new['col'] = r['col']; new['ncol'] = norm_col(L[line - 1], r['col']) + dup = [l for l in links if l['file'] == f and l['line'] == line and l['target'] == new['target'] and l.get('col') == new.get('col')] + links = [l for l in links if l not in dup] + [new] + t0 = time.time() + write_links(repo, links); _restore_bad(repo, bad) + res = apply_repo(repo) + ms = (time.time() - t0) * 1000 + note_ = next((x['reason'] for live, rs in res.values() if live for l_, x in rs + if l_['file'] == f and l_['line'] == line and l_['target'] == new['target'] and l_.get('col') == new.get('col') and x['status'] in ('applied', 'moved')), '') + where = f"{f}:{line}" + (f":{new['col']}" if new.get('col') else '') + print(f"linked {where} `{new['callee']}` → {new['target']} [asserted]" + (" (the graph already had this edge; recorded, nothing added)" if r['status'] == 'redundant' else '') + + (f"; {note_}" if note_ else '') + f" — applied to {sum(1 for v in res.values() if v[0])} graph(s) in {ms:.0f} ms") + return 0 + + +def _restore_bad(repo, bad): + """a hand-written line write_links could not parse is kept as written, so a link is never lost to a typo""" + if not bad: return + with open(links_path(repo), 'a', encoding='utf-8') as fh: + for _n, raw, _w in bad: fh.write(raw + '\n') + + +if __name__ == '__main__': + sys.exit(main(sys.argv[1:])) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode index 2ebaa16d..6ab4eb36 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode @@ -12,6 +12,9 @@ # axiomcode context "" [--source] # how something works, as a narrative: the files and callables the task touches and, for a # how-does-X-work question, the call flow step by step; --source carries each step's code. +# axiomcode link +# record where an unresolved call lands, when the code makes it certain: impact, path and tests then walk it, +# labelled [asserted]. Alone, lists every link and whether the graph took it; `link -` removes one. # axiomcode index [] [--lang [,…]] [--src ] [--library [,…]] # build the graph (the first query builds it too). defaults to the current directory. # @@ -111,7 +114,7 @@ public_verbs(){ helptext | sed -n 's/^ axiomcode \([a-z][a-z-]*\).*/\1/p'; } # leading comment block. The verb documents itself once, where it is implemented. verbhelp(){ # a verb of the small surface is explained by its own entry in the help above: what it answers, no options - case "$1" in impact|path|tests) + case "$1" in impact|path|tests|link) helptext | awk -v v="$1" '$0 ~ "^ axiomcode "v"( |$)" {on=1; print; next} on && /^ axiomcode / {exit} on && /^$/ {exit} on {print}' return 0 ;; esac @@ -230,6 +233,7 @@ case "$cmd" in impact) exec ${G[@]+"${G[@]}"} python3 ${Q:+"$Q" "$FR"} "$H/axiomcode-impact" ${ARGS[@]+"${ARGS[@]}"} ;; changed) exec python3 ${Q:+"$Q" "$FR"} "$H/axiomcode-changed" ${ARGS[@]+"${ARGS[@]}"} ;; test-impact|tests) exec ${G[@]+"${G[@]}"} python3 ${Q:+"$Q" "$FR"} "$H/axiomcode-test-impact" ${ARGS[@]+"${ARGS[@]}"} ;; + link) exec python3 "$H/axiomcode-link" ${ARGS[@]+"${ARGS[@]}"} ;; --verbs) verbs ;; # the dispatch table, internal verbs included; tests/surfaces.py audits it ""|-h|--help|help) if [ ${#ARGS[@]} -gt 0 ]; then verbhelp "${ARGS[0]}"; else helptext; fi ;; *) echo "axiomcode: unknown subcommand '$cmd' — the entry point has: $(public_verbs | tr '\n' ' ')" >&2 diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index 9f3a4100..afbba2b4 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -159,7 +159,9 @@ def ckey(k): # 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, +# `asserted` (a link someone recorded where the engine resolved nothing, ax_links.py) is an edge, so it outranks every +# name match, and nobody resolved it, so it sits below `registered`, which the engine recorded itself. +CERT = {'resolved': 0, 'one of a set': 1, 'registered': 2, 'asserted': 2.5, 'capped set': 3, 'remote': 4, 'framework': 5, '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)""" @@ -916,6 +918,7 @@ class Impact: if not hasattr(self, '_ns'): import ax_nonsource indexed = {sy['file'] for sy in self.g.sym.values() if sy.get('file')} # a shebang script is source too (#1376) + indexed.add('axiomcode-links.tsv') # the asserted links (ax_links.py) name targets; they bind nothing at run time self._ns = ax_nonsource.NonSource(self.g.repo, self.g.facts, indexed, self.SKIP_DIR, self.SOURCE_EXT | self.PROSE_EXT) return self._ns def nonsource_files(self): @@ -2544,6 +2547,7 @@ def main(argv): # cut short and so lose every route that passes through code outside the path) to test files containing it tests_only = '--tests-only' in args; want_tests = '--tests' in args or tests_only; want_why = '--why' in args want_delete = '--delete' in args; want_warm = '--warm' in args; want_depends = '--depends' in args + want_unknown = '--unknown' in args; args = [a for a in args if a != '--unknown'] # the default view collapses a flood of [by name] rows; an explicit --limit or --page all asks for every row # A LATER PAGE CONTINUES THE ROWS (ax_pages.paginate). Page 1 is the default view; page 2 on holds the rows that # view left out. So whenever the pager captures the answer every row is printed, the ones the default view hides @@ -2852,6 +2856,12 @@ def main(argv): ent = [(m, d) for m, d in {**{c: 0 for c in direct_ids}, **reached}.items() if m in ent_why] inside = list({*reached, *seeds, *direct_ids}) # the direct rows are not in seed for a method target u = g.q(f"SELECT count(*) n FROM unresolved_sites WHERE caller_id IN ({','.join('?' * len(inside))})", *inside)[0]['n'] if inside else 0 + # THE GAPS AS A WORK LIST (ax_links.py): the unresolved sites behind that count, nearest the change first, each with the + # call as written and the engine's own reason, so an agent can read them and link the one its task depends on + import ax_links + unk, _unk_n = ax_links.unknown_sites(g.q, inside, g.site_file, limit=30 if as_json else 200, + order=[*seeds, *sorted(direct_ids, key=g.disp), *sorted(reached, key=lambda m: (reached[m], g.disp(m)))], repo=g.repo) + links_note = ax_links.note(g.q) # the check that means something: every printed chain hop, and every resolved direct entry, is looked up again in graph.sqlite chains = {m: I.chain(m, parent) for m in tests} # the ROUTE a record carries: a test reached through a fixture (a setUp, a @MethodSource factory) has no edge of @@ -2969,7 +2979,9 @@ def main(argv): 'filter': 'registered', # a library summarised (from its source) to call a client member back: a member name on a type the client # handed over, not a resolved call (framework-behavior/library-callbacks.dl) - 'library callback': 'library callback'} + 'library callback': 'library callback', + # a link someone recorded (ax_links.py): an edge nobody resolved — its own rung, beside `registered` + 'asserted': 'asserted'} # The numbers that used to stand here (sound 0.645, one of a set 0.414, by key 0.396, fixture 0.233, by name # 0.133) were measured against a truth set that was SHORT: a pytest file which fails to COLLECT is emitted in # junit XML with classname="" and the module in `name`, the oracle read classname alone, and so every @@ -3007,7 +3019,7 @@ def main(argv): # is strong. It sits below `one of a set` all the same, because it is not a resolved call and should not borrow # the word that means one. CERT_RANK = {'sound': 0, 'defines': 1, 'at import': 2, 'at load': 2, 'one of a set': 3, 'dispatch': 4, 'registered': 5, - 'library callback': 5, 'remote': 6, 'spawns': 7, 'by key': 8, 'decorator by name': 9, 'protocol': 10, 'fixture': 11, + 'library callback': 5, 'asserted': 5, 'remote': 6, 'spawns': 7, 'by key': 8, 'decorator by name': 9, 'protocol': 10, 'fixture': 11, 'by name': 12} # `remote` (#1293) sits just above `by key`: both join two ends on a string, but the engine matched a remote # hop's whole route and verb on both ends, where a key join is this file's own rules matching one literal. Not @@ -3090,7 +3102,7 @@ def main(argv): # resolved row does and are verified the same way — the why-wording test cannot speak for them, because # a hand-off's sentence deliberately does not say "calls". if (cert == 'resolved' and ('calls' in why or 'through' in why or 'constructs' in why or 'instantiates' in why or 'passes an argument' in why)) \ - or cert in ('registered', 'capped set'): + or cert in ('registered', 'asserted', 'capped set'): hops += 1 if not any((c, t) in real for t in asserted.get(c, ())): bad += 1 prose_hits = getattr(getattr(I, '_ns', None), 'prose', set()) @@ -3133,6 +3145,7 @@ def main(argv): 'byname_callers': [{'id': c, 'display': g.disp(c), 'at': g.loc(c)} for c in sorted(byname_seeds, key=by_place)], 'external': [{'at': f'{f}:{l}', 'name': n, 'how': how, **({'prose': True} if (n, f, l) in prose_hits else {})} for f, l, n, how in sorted({(r[0], int(r[1]), r[2], r[3]) for r in res.get('extbind', [])})], 'test_universe': universe, **({'tests_outside_src': dict(zip(('src', 'files'), out_src))} if out_src[1] else {}), 'verified': bad == 0, 'checked_hops': hops, 'unresolved_inside': u, + 'unknown_sites': unk, 'unknown_total': _unk_n, **({'links_note': links_note} if links_note else {}), **({'why': [g.why_json(n) for n in target_why]} if want_why else {})}) if ax_evidence.on() or _dropped: if _dropped: doc['asked_again'] = {'note': ax_evidence.ask_again_note(), 'dropped': [g.loc(x[0]) if not x[4] else x[4] for x in _dropped]} @@ -3422,7 +3435,9 @@ def main(argv): # the search that lists them, printed each under --page all / --limit N, and kept in --json with "prose": true. ext_prose = [x for x in ext if (x[2], x[0], x[1]) in prose_hits] ext = [x for x in ext if (x[2], x[0], x[1]) not in prose_hits] - print(f"reaches those through resolved calls: {len(reached)} more callable(s) in {nfiles} file(s)" + (f" within {depth} hop(s)" if depth < 40 else '') + (f" under *{IN}*" if IN else '')) + # an ASSERTED hop (axiomcode link) is not a resolved call: when the closure crosses one, the line says so + _nas = len({(a_, b_) for a_, bs in parent.items() for b_, t_ in bs if t_ == 'asserted'}) + print(f"reaches those through resolved calls" + (f" and {_nas} asserted link(s)" if _nas else '') + f": {len(reached)} more callable(s) in {nfiles} file(s)" + (f" within {depth} hop(s)" if depth < 40 else '') + (f" under *{IN}*" if IN else '')) weak = [m for m in reached if m not in sure_set] if weak: print(f" {len(weak)} of those {len(reached)} are reached ONLY through a dependent that is not an exact edge ([by name], [text], [one of a set]): they stand or fall with it") if reached: @@ -3694,7 +3709,7 @@ def main(argv): # called local, because `next:` read only the direct rows. # `remote` (#1468): the client that calls it across a process is a dependent the engine joined at both ends, so it # is a place to read, not a by-name lead, and it makes the declaration as non-local as a resolved caller does - STRONG_DIRECT = ('resolved', 'one of a set', 'registered', 'capped set', 'remote', 'framework') + STRONG_DIRECT = ('resolved', 'one of a set', 'registered', 'asserted', 'capped set', 'remote', 'framework') prod_users = {c for c, role, why, cert, loc, n in D if cert in STRONG_DIRECT and not _is_t(c)} prod_reach = sorted((m for m in reached if m in sure_set and not _is_t(m)), key=lambda m: (reached[m], g.loc(m))) lone = not prod_users and not prod_reach @@ -3738,7 +3753,7 @@ def main(argv): # reference. Letting the new label fall out of this list would turn "something still uses this" into # "safe to delete", which is the one verdict here that costs anything to get wrong. calls = [x for x in D if (x[3] == 'resolved' and ('calls' in x[2] or 'instantiates' in x[2] or 'through' in x[2] or 'inject' in x[2])) - or x[3] in ('registered', 'capped set', 'framework')] # a task's producer, a signal's sender (#1509) + or x[3] in ('registered', 'asserted', 'capped set', 'framework')] # a task's producer, a signal's sender (#1509) weak = [x for x in D if x[3] in ('one of a set', 'by name', 'text', 'in scope')] lits = [r for n_ in names for r in g.q("SELECT file, line FROM literals WHERE value = ?", n_)] if g.has('literals') else [] decs = [f"{n_} ({f_}:{l_})" if f_ and l_ else n_ for n_, f_, l_ in own_decs] @@ -3868,6 +3883,8 @@ def main(argv): print("bound: the closure could not start — no dependent above is an edge it can walk back from, so what " "reaches THEM is not in this answer") if u: print(f"bound: {u} unresolved call(s) inside the impacted set — what those reach is unknown, so the set is a lower bound") + if want_unknown and unk: print('\n'.join(ax_links.unknown_lines(g.repo, unk, u))) + if links_note: print(links_note) # A STRUCTURAL TYPE IS SATISFIED WITHOUT BEING NAMED (#784). `return { kind: 'LiteralNode', fragments }` checks # against the interface and never writes it, so an added required property or a narrowed one breaks sites no row # above can name. Assignability is the compiler's job; the answer says so rather than looking complete. diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index index 9dae7ca8..9add01cd 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index @@ -70,7 +70,11 @@ if not HAVE_IR and prev.get('ir_present') == 'true' and '--force' not in sys.arg raw_paths = {r[0] for t in ('call_sites', 'methods', 'types') for r in c.execute(f"SELECT DISTINCT file_path FROM {t} WHERE file_path IS NOT NULL")} c.executemany("INSERT OR IGNORE INTO paths VALUES (?,?)", ((p, rel(p)) for p in raw_paths)) c.execute("DELETE FROM index_meta WHERE key='index_version'"); c.execute("INSERT INTO index_meta VALUES ('index_version', ?)", (INDEX_VERSION,)) # or every query would retry this - con.commit(); print(f"index kept: IR is gone but the index was built from it ({prev.get('built_at')}); newer tables added empty. Rebuild the graph to fill them."); sys.exit(0) + con.commit() + try: + sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))); import ax_links; ax_links.apply_db(DB, REPO, ax_links.Reader(REPO)) + except Exception: pass + print(f"index kept: IR is gone but the index was built from it ({prev.get('built_at')}); newer tables added empty. Rebuild the graph to fill them."); sys.exit(0) # ── the adapter table: which IR file and which HEADER NAMES carry each thing, per language ──────── # ref: expression rows that are a use of a name. kinds -> the expression kinds; name -> the @@ -791,4 +795,16 @@ meta = dict(language=LANG, repo=REPO, source_dir=SRC, ir=IR if HAVE_IR else '', c.executemany("INSERT INTO index_meta VALUES (?,?)", [(k, str(v)) for k, v in meta.items()]) con.commit() con.execute("VACUUM") # the tables above are dropped and recreated on every run; without this the file only grows +con.close() +# THE ASSERTED LINKS (ax_links.py, axiomcode-links.tsv) are applied to every graph as it is indexed, after the engine's +# export and before the graph is published, so a rebuild keeps them and no query ever sees a graph without them: each is +# validated against THIS graph and this tree's text, O(links), and one whose line changed is dropped and reported. +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +try: + import ax_links + _t0 = time.time(); _res = ax_links.apply_db(DB, REPO, ax_links.Reader(REPO)) + if _res: print(f"links: {sum(1 for _l, r in _res if r['status'] in ('applied', 'moved'))} of {len(_res)} asserted link(s) applied in {(time.time() - _t0) * 1000:.0f} ms") +except Exception as e: # a links file can never cost the graph + print(f"links: not applied ({e})") +con = sqlite3.connect(DB); c = con.cursor() print(f"indexed {DB} [{LANG}]: symbols={meta['symbols']} refs={meta['refs']} literals={meta['literals']} comments={meta['comments']} nested_types={meta['nested_types']}" + ("" if HAVE_IR else " (IR absent: refs/literals/comments empty — `uses` will say so)")) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-link b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-link new file mode 100755 index 00000000..d8f96c6c --- /dev/null +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-link @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +"""axiomcode link [] [] +axiomcode link [] every asserted link, and whether the graph took it (--json) +axiomcode link - [] remove the links at that site (or --remove []) + +Records that the call written at file:line reaches — a declaration as the graph names it (Owner.method, +function) or its file:line — where the graph could not resolve it. The link is kept in axiomcode-links.tsv at the +repository root, validated against the graph (a call written there, consistent with the target; the target declared; +the line's text unchanged), and applied at once as an edge of tier `asserted`, which impact, path and tests walk and +label [asserted]. A stale or rejected link is listed with its reason and never applied. See ax_links.py. +""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import ax_links + +if __name__ == '__main__': + sys.exit(ax_links.main(sys.argv[1:])) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path index dfdf5441..ed0a0257 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path @@ -57,6 +57,19 @@ UNRESOLVED = [] # endpoints G.resolve found # Filled as the human output is produced, from the SAME chain objects, so the two cannot disagree: # a second traversal for the machine shape is a second implementation to keep in step. RESULT = {'query': None, 'answers': [], 'reached': [], 'bound': None, 'verified': None} +WANT_UNKNOWN = False + + +def unknown_block(g, callers): + """THE GAPS AS A WORK LIST (ax_links.py): the unresolved sites in these callables, each with the call as written and + the engine's reason, in --json always and in the prose with --unknown; and the note when a link was not applied""" + import ax_links + sites, n = ax_links.unknown_sites(g.q, list(callers), g.site_file, limit=30 if not WANT_UNKNOWN else 200, repo=g.repo) + RESULT['unknown_sites'] = sites; RESULT['unknown_total'] = n + if WANT_UNKNOWN and sites: + for l in ax_links.unknown_lines(g.repo, sites, n): print(' ' + l) + n_ = ax_links.note(g.q) + if n_: RESULT['links_note'] = n_; print(' ' + n_) def chain_json(g, chain): @@ -1903,7 +1916,10 @@ def path(g, a, b, show_all=False, limit=10, every=False, max_paths=20): if not hits: continue near = read_back(res['parent'], q, hits[0][0], srcs) or [] outside = sorted({t for _, t in near[1:] if t in ax_edges.OUTSIDE_CALL}) + # a hop someone ASSERTED (axiomcode link) is not a resolved call: the header says the chain rests on it + nas = sum(1 for _, t in near[1:] if t == 'asserted') print(f"{word}: {len(hits)} of {len(dsts)} target(s) reached through resolved calls" + + (f" and {nas} asserted link(s) (an edge someone recorded, not one the engine resolved)" if nas else '') + (f" and a hop no call site expresses ([{'] and ['.join(outside)}], below)" if outside else '') + f"; nearest at {hits[0][1]} hop(s)") if len(hits) > limit and not show_all: @@ -1955,9 +1971,15 @@ def path(g, a, b, show_all=False, limit=10, every=False, max_paths=20): if u: RESULT['bound'] = f"the methods on the nearest chain contain {u} unresolved call(s) — other chains may exist that the graph cannot see" print(f" bound: {RESULT['bound']}") + unknown_block(g, blind) return 0 # nothing resolved either way: say so, then whether unresolved sites would connect them print(f"no chain of resolved calls connects {la} and {lb} in either direction (searched {len(g.edges())} edges, depth ≤ 40)") + # where a missing hop can be: an unresolved call somewhere A reaches (the start's own closure, nearest first) + _fa = [i for i in A_ if i in g.sym]; _seen = set(_fa); _fr = list(_fa) + while _fr: + _fr = [y for y in dict.fromkeys(y for x in _fr for y in adj.get(x, ())) if y not in _seen and y in g.sym] + _seen.update(_fr); _fa += _fr # A CROSS-PROCESS HOP IS A CONNECTION, AND THIS SAID THERE WAS NONE (#1108). A gRPC client and the # handler it calls, or a producer and its consumer, are joined by an edge no call site expresses, so # the BFS above cannot reach it and the honest-sounding "the two are independent in this graph" was @@ -2006,6 +2028,7 @@ def path(g, a, b, show_all=False, limit=10, every=False, max_paths=20): for c, m2 in bridges: sites = g.q("SELECT s.file_path f, s.start_line ln FROM call_sites s JOIN unresolved_sites u ON u.call_site_id = s.id WHERE s.caller_id = ? AND s.callee_name = ?", c, g.sym[m2]['name']) print(f" `{g.sym[m2]['name']}` called in {g.disp(c)} at " + ', '.join(f"{g.site_file(s['f'])}:{s['ln']}" for s in sites[:4]) + f" — receiver not typed; if it is {g.disp(m2)}, the chain is real") + unknown_block(g, _fa) return 1 # A LIBRARY CALL IS WHERE THE GRAPH STOPS, NOT WHERE THE CODE DOES (#1385). A start that publishes an event # through a framework's publisher reaches the listener at run time, and the call that does it is a resolved @@ -2168,6 +2191,7 @@ def path(g, a, b, show_all=False, limit=10, every=False, max_paths=20): + (f" — named like the target: if it holds {other}, the chain is real" if r['n'] in names[lx] else '')) if len(rows) > 4: print(f" … +{len(rows) - 4} more") for line in fw: print(line) + unknown_block(g, _fa) return 1 # ── self-test: the engine's expected edges, through the whole tool ─────────────────────────────────────────────── @@ -2244,6 +2268,7 @@ if __name__ == '__main__': if not args or args[0] in ('-h', '--help'): print(__doc__); sys.exit(0) if args[0] == '--selftest': sys.exit(selftest(args[1] if len(args) > 1 else 'typescript')) as_json = '--json' in args; args = [a for a in args if a != '--json'] + WANT_UNKNOWN = '--unknown' in args; args = [a for a in args if a != '--unknown'] want_why = '--why' in args; args = [a for a in args if a != '--why'] show_all = '--all' in args; args = [a for a in args if a != '--all'] depth = 40 diff --git a/skills/axiomcode/SKILL.md b/skills/axiomcode/SKILL.md index 852dd585..d7b3045e 100644 --- a/skills/axiomcode/SKILL.md +++ b/skills/axiomcode/SKILL.md @@ -17,6 +17,7 @@ Search with grep as usual; the graph answers what grep cannot. Use the MCP tools | what do my uncommitted edits reach? | `impact()` | `axiomcode impact` | | how does A reach B? | `path(start, end)` | `axiomcode path ` | | which tests do my edits need, and how do I run them? | `tests()` | `axiomcode tests` | +| an answer lists an unresolved call I can see the target of | `link(site, target)` | `axiomcode link ` | Names are written as in the code: `Owner.method`, `function`, `Type`, or `file.py:123` for the declaration at that line. There is no setup step: the first question builds the graph, and it refreshes itself after every edit. @@ -56,6 +57,19 @@ Example: `path(start="main", end="Ledger.put")`. The tests your uncommitted edits reach, each with its code, and a last line `run: ` that runs exactly those. Example: `tests()`. It is a lower bound: a test reached only through reflection or a service loader is not listed. +## link + +An answer that stops at a call the graph could not resolve lists it under `unknown:` — the site as `file:line`, the +call as written and why the engine could not follow it (a value from `getattr`, a handler table, reflection, a +callback). When the task depends on one of those sites, read the code; if it makes the target CERTAIN, record it: +`link(site="app/dispatch.py:6", target="on_save")`, or `axiomcode link app/dispatch.py:6 on_save` from the shell. +From then on impact, path and tests walk that edge, labelled `[asserted]`, never `resolved`. The links are kept in +`axiomcode-links.tsv` at the repository root, which is worth committing. `link()` with no arguments lists them and +whether the graph took each one; `axiomcode link -` removes one. A link is refused when the call written +there names a different declaration, or the target is not one; when the line it was made on is edited, it is +dropped and listed as stale, and the site is unknown again. Never link a guess: an asserted edge is trusted by every +answer after it. + ## context How something works, from a task in your own words: the files and callables the task touches and, for a diff --git a/tests/cases/csharp/asserted-links/App.Tests/DispatchTests.cs b/tests/cases/csharp/asserted-links/App.Tests/DispatchTests.cs new file mode 100644 index 00000000..a34a43ba --- /dev/null +++ b/tests/cases/csharp/asserted-links/App.Tests/DispatchTests.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using App; +using Xunit; + +namespace App.Tests +{ + public class DispatchTests + { + [Fact] + public void Saves() + { + Assert.NotNull(Dispatch.Run("OnSave", new Dictionary())); + } + } +} diff --git a/tests/cases/csharp/asserted-links/App/Dispatch.cs b/tests/cases/csharp/asserted-links/App/Dispatch.cs new file mode 100644 index 00000000..e7ef7591 --- /dev/null +++ b/tests/cases/csharp/asserted-links/App/Dispatch.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using System.Reflection; + +namespace App +{ + public static class Dispatch + { + public static object Run(string evt, Dictionary doc) + { + MethodInfo m = typeof(Handlers).GetMethod(evt); + return m.Invoke(null, new object[] { doc }); + } + + public static object RunTable(Dictionary, object>> table, string key, Dictionary doc) + { + return table[key](doc); + } + + public static object Purge(Dictionary doc) + { + MethodInfo m = typeof(Handlers).GetMethod("OnPurge"); + return m.Invoke(null, new object[] { doc }); + } + + public static object SaveAndAudit(Dictionary doc) + { + return Handlers.Audit(doc); + } + } +} diff --git a/tests/cases/csharp/asserted-links/App/Handlers.cs b/tests/cases/csharp/asserted-links/App/Handlers.cs new file mode 100644 index 00000000..53f1cb9b --- /dev/null +++ b/tests/cases/csharp/asserted-links/App/Handlers.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; + +namespace App +{ + public static class Handlers + { + public static Dictionary OnSave(Dictionary doc) + { + return Audit(doc); + } + + public static Dictionary OnLoad(Dictionary doc) + { + return doc; + } + + public static Dictionary OnPurge(Dictionary doc) + { + return doc; + } + + public static Dictionary Audit(Dictionary doc) + { + doc["audited"] = true; + return doc; + } + } +} diff --git a/tests/cases/csharp/asserted-links/case.json b/tests/cases/csharp/asserted-links/case.json new file mode 100644 index 00000000..3ecbb83b --- /dev/null +++ b/tests/cases/csharp/asserted-links/case.json @@ -0,0 +1,169 @@ +{ + "lang": "csharp", + "checks": [ + { + "why": "BASE: a reflective MethodInfo.Invoke ends in the library, so no chain reaches the handler it runs", + "run": [ + "path", + "DispatchTests.Saves", + "Handlers.Audit" + ], + "expect_error": true, + "want": [ + "no chain of resolved calls connects DispatchTests.Saves and Handlers.Audit" + ] + }, + { + "why": "the reflective call is listed as a work item: resolved into the library, what it runs unknown", + "run": [ + "impact", + "Dispatch.Run", + "--unknown" + ], + "want": [ + "App/Dispatch.cs:12:22 return m.Invoke(null, new object[] { doc }); [runs a value through MethodInfo.Invoke]" + ] + }, + { + "why": "link records the target of the reflective call", + "run": [ + "link", + "App/Dispatch.cs:12", + "Handlers.OnSave" + ], + "want": [ + "linked App/Dispatch.cs:12:22 `Invoke` → Handlers.OnSave [asserted]" + ] + }, + { + "why": "path walks the asserted edge, labelled [asserted]", + "run": [ + "path", + "DispatchTests.Saves", + "Handlers.Audit" + ], + "want": [ + "[asserted · call @ App/Dispatch.cs:12] Handlers.OnSave", + "reached through resolved calls and 1 asserted link(s)", + "[asserted] ASSERTED by a link" + ], + "avoid": [ + "target(s) reached through resolved calls;" + ] + }, + { + "why": "impact lists the caller as asserted and selects the test through it", + "run": [ + "impact", + "Handlers.OnSave", + "--tests" + ], + "want": [ + "[asserted] Dispatch.Run", + "DispatchTests::Saves", + "reaches those through resolved calls and 1 asserted link(s)" + ] + }, + { + "why": "a delegate held in a table, called by its holder's name, can be linked too", + "run": [ + "link", + "App/Dispatch.cs:17", + "Handlers.OnLoad" + ], + "want": [ + "linked App/Dispatch.cs:17:20 `table` → Handlers.OnLoad [asserted]" + ] + }, + { + "why": "CONTROL: a link to a declaration that does not exist is rejected", + "run": [ + "link", + "App/Dispatch.cs:12", + "Handlers.OnArchive" + ], + "expect_error": true, + "want": [ + "rejected — the target is not a declaration in this graph" + ] + }, + { + "why": "CONTROL: a link at a line with no call is rejected", + "run": [ + "link", + "App/Dispatch.cs:3", + "Handlers.OnLoad" + ], + "expect_error": true, + "want": [ + "rejected — no call is written at App/Dispatch.cs:3" + ] + }, + { + "why": "CONTROL: a call that names another declaration cannot be linked to an unrelated target", + "run": [ + "link", + "App/Dispatch.cs:28", + "Handlers.OnLoad" + ], + "expect_error": true, + "want": [ + "rejected — the call as written names `Audit`" + ] + }, + { + "why": "CONTROL: an unlinked sibling reflective site stays unknown", + "run": [ + "impact", + "Dispatch.Purge", + "--unknown" + ], + "want": [ + "App/Dispatch.cs:23:22 return m.Invoke(null, new object[] { doc }); [runs a value through MethodInfo.Invoke]" + ], + "avoid": [ + "App/Dispatch.cs:23:22 return m.Invoke(null, new object[] { doc }); [runs a value through MethodInfo.Invoke] → linked" + ] + }, + { + "why": "lines inserted above the site: after the rebuild the link follows the line by its text", + "edit": [ + "App/Dispatch.cs", + "using System.Reflection;\n", + "using System.Reflection;\n// dispatch by event name\n\n" + ], + "run": [ + "path", + "DispatchTests.Saves", + "Handlers.Audit", + "--fresh" + ], + "want": [ + "[asserted · call @ App/Dispatch.cs:14] Handlers.OnSave" + ] + }, + { + "why": "the linked line itself edited: after the rebuild the link is dropped and the answer says so", + "edit": [ + "App/Dispatch.cs", + " return m.Invoke(null, new object[] { doc });\n }\n\n public static object RunTable", + " return m.Invoke(null, new object[] { new Dictionary(doc) });\n }\n\n public static object RunTable" + ], + "run": [ + "path", + "DispatchTests.Saves", + "Handlers.Audit", + "--fresh" + ], + "expect_error": true, + "want": [ + "no chain of resolved calls connects", + "not applied", + "1 stale" + ], + "avoid": [ + "[asserted" + ] + } + ] +} diff --git a/tests/cases/java/asserted-links/case.json b/tests/cases/java/asserted-links/case.json new file mode 100644 index 00000000..07f9684c --- /dev/null +++ b/tests/cases/java/asserted-links/case.json @@ -0,0 +1,161 @@ +{ + "lang": "java", + "src": "src", + "checks": [ + { + "why": "BASE: a reflective Method.invoke ends in the library, so no chain reaches the handler it runs", + "run": [ + "path", + "DispatchTest.save", + "Handlers.audit" + ], + "expect_error": true, + "want": [ + "no chain of resolved calls connects DispatchTest.save and Handlers.audit" + ] + }, + { + "why": "the reflective call is listed as a work item: resolved into the library, what it runs unknown", + "run": [ + "impact", + "Dispatch.dispatch", + "--unknown" + ], + "want": [ + "src/pkg/Dispatch.java:10:18 return m.invoke(null, doc); [runs a value through java.lang.reflect.Method.invoke]" + ] + }, + { + "why": "link records the target of the reflective call", + "run": [ + "link", + "src/pkg/Dispatch.java:10", + "Handlers.onSave" + ], + "want": [ + "linked src/pkg/Dispatch.java:10:18 `invoke` → Handlers.onSave [asserted]" + ] + }, + { + "why": "path walks the asserted edge, labelled [asserted]", + "run": [ + "path", + "DispatchTest.save", + "Handlers.audit" + ], + "want": [ + "[asserted · call @ src/pkg/Dispatch.java:10] Handlers.onSave", + "reached through resolved calls and 1 asserted link(s)", + "[asserted] ASSERTED by a link" + ], + "avoid": [ + "target(s) reached through resolved calls;" + ] + }, + { + "why": "impact lists the caller as asserted and selects the test through it", + "run": [ + "impact", + "Handlers.onSave", + "--tests" + ], + "want": [ + "[asserted] Dispatch.dispatch", + "DispatchTest::save", + "reaches those through resolved calls and 1 asserted link(s)" + ], + "avoid": [ + "DispatchTest::table" + ] + }, + { + "why": "CONTROL: a link to a declaration that does not exist is rejected", + "run": [ + "link", + "src/pkg/Dispatch.java:10", + "Handlers.onArchive" + ], + "expect_error": true, + "want": [ + "rejected — the target is not a declaration in this graph" + ] + }, + { + "why": "CONTROL: a link at a line with no call is rejected", + "run": [ + "link", + "src/pkg/Dispatch.java:3", + "Handlers.onLoad" + ], + "expect_error": true, + "want": [ + "rejected — no call is written at src/pkg/Dispatch.java:3" + ] + }, + { + "why": "CONTROL: a call that names another declaration cannot be linked to an unrelated target", + "run": [ + "link", + "src/pkg/Dispatch.java:23", + "Handlers.onLoad" + ], + "expect_error": true, + "want": [ + "rejected — the call as written names `audit`" + ] + }, + { + "why": "CONTROL: an unlinked sibling reflective site stays unknown", + "run": [ + "impact", + "Dispatch.purge", + "--unknown" + ], + "want": [ + "src/pkg/Dispatch.java:19:18 return m.invoke(null, doc);" + ], + "avoid": [ + "src/pkg/Dispatch.java:19:18 return m.invoke(null, doc); [runs a value through java.lang.reflect.Method.invoke] → linked" + ] + }, + { + "why": "lines inserted above the site: after the rebuild the link follows the line by its text", + "edit": [ + "src/pkg/Dispatch.java", + "import java.util.Map;\n", + "import java.util.Map;\n// dispatch by event name\n\n" + ], + "run": [ + "path", + "DispatchTest.save", + "Handlers.audit", + "--fresh" + ], + "want": [ + "[asserted · call @ src/pkg/Dispatch.java:12] Handlers.onSave" + ] + }, + { + "why": "the linked line itself edited: after the rebuild the link is dropped and the answer says so", + "edit": [ + "src/pkg/Dispatch.java", + " return m.invoke(null, doc);\n }\n\n public static Object runTable", + " return m.invoke(null, new java.util.HashMap<>(doc));\n }\n\n public static Object runTable" + ], + "run": [ + "path", + "DispatchTest.save", + "Handlers.audit", + "--fresh" + ], + "expect_error": true, + "want": [ + "no chain of resolved calls connects", + "links: 1 of 1 asserted link(s) not applied (1 stale)" + ], + "avoid": [ + "[asserted" + ] + } + ] +} diff --git a/tests/cases/java/asserted-links/src/pkg/Dispatch.java b/tests/cases/java/asserted-links/src/pkg/Dispatch.java new file mode 100644 index 00000000..c7ceb0da --- /dev/null +++ b/tests/cases/java/asserted-links/src/pkg/Dispatch.java @@ -0,0 +1,25 @@ +package pkg; + +import java.lang.reflect.Method; +import java.util.Map; +import java.util.function.Function; + +public class Dispatch { + public static Object dispatch(String event, Map doc) throws Exception { + Method m = Handlers.class.getMethod(event, Map.class); + return m.invoke(null, doc); + } + + public static Object runTable(Map, Object>> table, String key, Map doc) { + return table.get(key).apply(doc); + } + + public static Object purge(Map doc) throws Exception { + Method m = Handlers.class.getMethod("onPurge", Map.class); + return m.invoke(null, doc); + } + + public static Object saveAndAudit(Map doc) { + return Handlers.audit(doc); + } +} diff --git a/tests/cases/java/asserted-links/src/pkg/DispatchTest.java b/tests/cases/java/asserted-links/src/pkg/DispatchTest.java new file mode 100644 index 00000000..b833d440 --- /dev/null +++ b/tests/cases/java/asserted-links/src/pkg/DispatchTest.java @@ -0,0 +1,20 @@ +package pkg; + +import java.util.HashMap; +import java.util.Map; +import java.util.function.Function; +import org.junit.Test; + +public class DispatchTest { + @Test + public void save() throws Exception { + assert Dispatch.dispatch("onSave", new HashMap<>()) != null; + } + + @Test + public void table() { + Map, Object>> t = new HashMap<>(); + t.put("load", Handlers::onLoad); + assert Dispatch.runTable(t, "load", new HashMap<>()) != null; + } +} diff --git a/tests/cases/java/asserted-links/src/pkg/Handlers.java b/tests/cases/java/asserted-links/src/pkg/Handlers.java new file mode 100644 index 00000000..e3f2d64c --- /dev/null +++ b/tests/cases/java/asserted-links/src/pkg/Handlers.java @@ -0,0 +1,22 @@ +package pkg; + +import java.util.Map; + +public class Handlers { + public static Map onSave(Map doc) { + return audit(doc); + } + + public static Map onLoad(Map doc) { + return doc; + } + + public static Map onPurge(Map doc) { + return doc; + } + + public static Map audit(Map doc) { + doc.put("audited", true); + return doc; + } +} diff --git a/tests/cases/javascript/asserted-links/case.json b/tests/cases/javascript/asserted-links/case.json new file mode 100644 index 00000000..5525987c --- /dev/null +++ b/tests/cases/javascript/asserted-links/case.json @@ -0,0 +1,170 @@ +{ + "lang": "javascript", + "src": "src", + "checks": [ + { + "why": "BASE: a call through a value read by a key built at run time is unresolved, so no chain reaches the handler it runs", + "run": [ + "path", + "purge", + "audit" + ], + "expect_error": true, + "want": [ + "no chain of resolved calls connects purge and audit" + ] + }, + { + "why": "the unresolved site is listed as a work item", + "run": [ + "impact", + "purge", + "--unknown" + ], + "want": [ + "src/dispatch.js:15:10 return fn(doc);" + ] + }, + { + "why": "link records the target of the call at that line", + "run": [ + "link", + "src/dispatch.js:15", + "onPurge" + ], + "want": [ + "linked src/dispatch.js:15:10 `fn` → onPurge [asserted]" + ] + }, + { + "why": "path walks the asserted edge, labelled [asserted]", + "run": [ + "path", + "purge", + "audit" + ], + "want": [ + "[asserted · call @ src/dispatch.js:15] onPurge", + "reached through resolved calls and 1 asserted link(s)", + "[asserted] ASSERTED by a link" + ], + "avoid": [ + "target(s) reached through resolved calls;" + ] + }, + { + "why": "impact lists the caller as asserted and selects the test through it", + "run": [ + "impact", + "onPurge", + "--tests" + ], + "want": [ + "[asserted] purge", + "dispatch.test.js", + "reaches those through resolved calls and 1 asserted link(s)" + ] + }, + { + "why": "a link duplicating an edge the engine resolved adds nothing", + "run": [ + "link", + "src/dispatch.js:10", + "onLoad" + ], + "want": [ + "the graph already had this edge; recorded, nothing added" + ] + }, + { + "why": "CONTROL: a link to a declaration that does not exist is rejected", + "run": [ + "link", + "src/dispatch.js:15", + "onArchive" + ], + "expect_error": true, + "want": [ + "rejected — the target is not a declaration in this graph" + ] + }, + { + "why": "CONTROL: a link at a line with no call is rejected", + "run": [ + "link", + "src/dispatch.js:3", + "onLoad" + ], + "expect_error": true, + "want": [ + "rejected — no call is written at src/dispatch.js:3" + ] + }, + { + "why": "CONTROL: a call that names another declaration cannot be linked to an unrelated target", + "run": [ + "link", + "src/dispatch.js:19", + "onLoad" + ], + "expect_error": true, + "want": [ + "rejected — the call as written names `audit`" + ] + }, + { + "why": "CONTROL: an unlinked sibling site stays unknown", + "run": [ + "impact", + "reload", + "--unknown" + ], + "want": [ + "src/dispatch.js:24:10 return fn(doc);" + ], + "avoid": [ + "→ linked" + ] + }, + { + "why": "lines inserted above the site: after the rebuild the link follows the line by its text", + "edit": [ + "src/dispatch.js", + "import * as handlers from './handlers';\n", + "import * as handlers from './handlers';\n// dispatch by event name\n\n" + ], + "run": [ + "path", + "purge", + "audit", + "--fresh" + ], + "want": [ + "[asserted · call @ src/dispatch.js:17] onPurge" + ] + }, + { + "why": "the linked line itself edited: after the rebuild the link is dropped and the answer says so", + "edit": [ + "src/dispatch.js", + " return fn(doc);\n}\n\nexport function saveAndAudit", + " return fn({ ...doc });\n}\n\nexport function saveAndAudit" + ], + "run": [ + "path", + "purge", + "audit", + "--fresh" + ], + "expect_error": true, + "want": [ + "no chain of resolved calls connects", + "not applied", + "1 stale" + ], + "avoid": [ + "[asserted" + ] + } + ] +} diff --git a/tests/cases/javascript/asserted-links/src/dispatch.js b/tests/cases/javascript/asserted-links/src/dispatch.js new file mode 100644 index 00000000..024a1da4 --- /dev/null +++ b/tests/cases/javascript/asserted-links/src/dispatch.js @@ -0,0 +1,25 @@ +import * as handlers from './handlers'; +// a document is a plain object + +export function dispatch(event, doc) { + const fn = (handlers)[event]; + return fn(doc); +} + +export function runTable(table, key, doc) { + return table[key](doc); +} + +export function purge(doc) { + const fn = (handlers)['on' + 'Purge']; + return fn(doc); +} + +export function saveAndAudit(doc) { + return handlers.audit(doc); +} + +export function reload(doc) { + const fn = (handlers)['on' + 'Load']; + return fn(doc); +} diff --git a/tests/cases/javascript/asserted-links/src/dispatch.test.js b/tests/cases/javascript/asserted-links/src/dispatch.test.js new file mode 100644 index 00000000..4dd450e0 --- /dev/null +++ b/tests/cases/javascript/asserted-links/src/dispatch.test.js @@ -0,0 +1,12 @@ +import { describe, it, expect } from 'vitest'; +import { purge, runTable } from './dispatch'; +import { onLoad } from './handlers'; + +describe('dispatch', () => { + it('purges', () => { + expect(purge({}).audited).toBe(true); + }); + it('runs a table', () => { + expect(runTable({ load: onLoad }, 'load', {})).toEqual({}); + }); +}); diff --git a/tests/cases/javascript/asserted-links/src/handlers.js b/tests/cases/javascript/asserted-links/src/handlers.js new file mode 100644 index 00000000..fbc084f0 --- /dev/null +++ b/tests/cases/javascript/asserted-links/src/handlers.js @@ -0,0 +1,18 @@ +// a document is a plain object + +export function onSave(doc) { + return audit(doc); +} + +export function onLoad(doc) { + return doc; +} + +export function onPurge(doc) { + return audit(doc); +} + +export function audit(doc) { + doc.audited = true; + return doc; +} diff --git a/tests/cases/python/asserted-links-derive/app/__init__.py b/tests/cases/python/asserted-links-derive/app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cases/python/asserted-links-derive/app/route.py b/tests/cases/python/asserted-links-derive/app/route.py new file mode 100644 index 00000000..ad15c371 --- /dev/null +++ b/tests/cases/python/asserted-links-derive/app/route.py @@ -0,0 +1,47 @@ +from app import views + + +def handle(name, req): + handler = getattr(views, name) + return handler(req).render() + + +def handle_local(name, req): + handler = getattr(views, name) + resp = handler(req) + text = resp.render() + resp.close() + return text + + +def handle_optional(name, req): + handler = getattr(views, name) + return handler(req).render() + + +def handle_builder(name, req): + handler = getattr(views, name) + return handler(req).step().done().render() + + +def handle_untyped(name, req): + handler = getattr(views, name) + out = handler(req) + return out.render() + + +async def handle_async(name, req): + handler = getattr(views, name) + resp = await handler(req) + return resp.render() + + +def handle_reassigned(name, req, other): + handler = getattr(views, name) + resp = handler(req) + resp = other + return resp.render() + + +def two_on_a_line(a, b): + return a.run() + b.run() diff --git a/tests/cases/python/asserted-links-derive/app/runners.py b/tests/cases/python/asserted-links-derive/app/runners.py new file mode 100644 index 00000000..e315fddd --- /dev/null +++ b/tests/cases/python/asserted-links-derive/app/runners.py @@ -0,0 +1,8 @@ +class Fast: + def run(self): + return 1 + + +class Slow: + def run(self): + return 2 diff --git a/tests/cases/python/asserted-links-derive/app/views.py b/tests/cases/python/asserted-links-derive/app/views.py new file mode 100644 index 00000000..a3b7efb9 --- /dev/null +++ b/tests/cases/python/asserted-links-derive/app/views.py @@ -0,0 +1,37 @@ +from typing import Optional + + +class Response: + def render(self) -> str: + return "ok" + + def close(self) -> None: + return None + + +class Builder: + def step(self) -> "Builder": + return self + + def done(self) -> Response: + return Response() + + +def make_response(req) -> Response: + return Response() + + +def maybe_response(req) -> Optional[Response]: + return Response() + + +def make_builder(req): + return Builder() + + +def untyped(req): + return req + + +async def fetch(req) -> Response: + return Response() diff --git a/tests/cases/python/asserted-links-derive/case.json b/tests/cases/python/asserted-links-derive/case.json new file mode 100644 index 00000000..4d65a0da --- /dev/null +++ b/tests/cases/python/asserted-links-derive/case.json @@ -0,0 +1,53 @@ +{"lang": "python", "src": ".", + "checks": [ + {"why": "BASE: the handler getattr returns is unknown, and so is the call on its result", + "run": ["path", "handle", "Response.render"], "expect_error": true, + "want": ["no chain of resolved calls connects handle and Response.render"]}, + {"why": "a link names a declaration with a return annotation: the call chained on the result resolves too", + "run": ["link", "app/route.py:6", "make_response"], + "want": ["linked app/route.py:6:12 `handler` → make_response [asserted]; derived 1 edge(s) on its result (Response, annotation)"]}, + {"why": "path walks the link, then the derived hop, both asserted", + "run": ["path", "handle", "Response.render"], + "want": ["[asserted · call @ app/route.py:6] Response.render"]}, + {"why": "a local assigned from the linked call: each call on it resolves", + "run": ["link", "app/route.py:11", "make_response"], + "want": ["derived 2 edge(s) on its result (Response, annotation)"]}, + {"why": "Optional[T] is T", + "run": ["link", "app/route.py:19", "maybe_response"], + "want": ["derived 1 edge(s) on its result (Response, annotation)"]}, + {"why": "no annotation: the type every return names, then a builder chain returning self and a typed method", + "run": ["link", "app/route.py:24", "make_builder"], + "want": ["derived 3 edge(s) on its result (Builder, inferred from its returns)"]}, + {"why": "a target with no return type derives nothing, and says so", + "run": ["link", "app/route.py:29", "untyped"], + "want": ["return type unknown, calls on its result stay unknown"]}, + {"why": "await on an async def gives its annotation", + "run": ["link", "app/route.py:35", "fetch"], + "want": ["derived 1 edge(s) on its result (Response, annotation)"]}, + {"why": "CONTROL: a local assigned twice is not followed", + "run": ["link", "app/route.py:41", "make_response"], + "want": ["`resp` is assigned more than once in handle_reassigned: calls on it stay unknown"]}, + {"why": "two calls of one name on a line: no column, no choice", + "run": ["link", "app/route.py:47", "Fast.run"], "expect_error": true, + "want": ["2 calls on that line could be it (`run` at column 14, `run` at column 24): give the column"]}, + {"why": "with the column each call takes its own target", + "run": ["link", "app/route.py:47:14", "Fast.run"], + "want": ["linked app/route.py:47:14 `run` → Fast.run [asserted]"]}, + {"why": "…and the other", + "run": ["link", "app/route.py:47:24", "Slow.run"], + "want": ["linked app/route.py:47:24 `run` → Slow.run [asserted]"]}, + {"why": "a column with no call is refused, naming the columns there", + "run": ["link", "app/route.py:47:20", "Slow.run"], "expect_error": true, + "want": ["no call's name starts at column 20 of app/route.py:47 (calls there: column 14, 24)"]}, + {"why": "each column reached its own target", + "run": ["path", "two_on_a_line", "Slow.run"], + "want": ["[asserted · call @ app/route.py:47] Slow.run"]}, + {"why": "the line re-spaced: the columns follow the text", + "edit": ["app/route.py", " return a.run() + b.run()", " return a.run() + b.run()"], + "run": ["path", "two_on_a_line", "Slow.run", "--fresh"], + "want": ["[asserted · call @ app/route.py:47] Slow.run"]}, + {"why": "editing the linked line drops the link AND what was derived from it", + "edit": ["app/route.py", " return handler(req).render()\n\n\ndef handle_local", " return handler(req, 1).render()\n\n\ndef handle_local"], + "run": ["path", "handle", "Response.render", "--fresh"], "expect_error": true, + "want": ["no chain of resolved calls connects handle and Response.render"], + "avoid": ["[asserted"]}]} diff --git a/tests/cases/python/asserted-links/app/__init__.py b/tests/cases/python/asserted-links/app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cases/python/asserted-links/app/dispatch.py b/tests/cases/python/asserted-links/app/dispatch.py new file mode 100644 index 00000000..f97d80f6 --- /dev/null +++ b/tests/cases/python/asserted-links/app/dispatch.py @@ -0,0 +1,23 @@ +from app import handlers + + +def dispatch(event, doc): + fn = getattr(handlers, event) + return fn(doc) + + +def run_table(table, key, doc): + return table[key](doc) + + +def purge(doc): + fn = getattr(handlers, "on_" + "purge") + return fn(doc) + + +def save_and_audit(doc): + return handlers.audit(doc) + + +def copy_doc(doc): + return doc.copy() diff --git a/tests/cases/python/asserted-links/app/handlers.py b/tests/cases/python/asserted-links/app/handlers.py new file mode 100644 index 00000000..e7410a6c --- /dev/null +++ b/tests/cases/python/asserted-links/app/handlers.py @@ -0,0 +1,15 @@ +def on_save(doc): + return audit(doc) + + +def on_load(doc): + return doc + + +def on_purge(doc): + return doc + + +def audit(doc): + doc["audited"] = True + return doc diff --git a/tests/cases/python/asserted-links/case.json b/tests/cases/python/asserted-links/case.json new file mode 100644 index 00000000..34bf1cd6 --- /dev/null +++ b/tests/cases/python/asserted-links/case.json @@ -0,0 +1,229 @@ +{ + "lang": "python", + "src": ".", + "checks": [ + { + "why": "BASE: a call through a value getattr returned is unresolved, so no chain reaches the handler it runs", + "run": [ + "path", + "test_save", + "audit" + ], + "expect_error": true, + "want": [ + "no chain of resolved calls connects test_save and audit" + ] + }, + { + "why": "the unresolved site is listed as a work item, with the call as written and the engine's reason", + "run": [ + "path", + "test_save", + "audit", + "--json" + ], + "expect_error": true, + "want": [ + "\"unknown_sites\"", + "app/dispatch.py:6", + "\"reason\": \"no_rule\"" + ] + }, + { + "why": "link records the target of the call at that line", + "run": [ + "link", + "app/dispatch.py:6", + "on_save" + ], + "want": [ + "linked app/dispatch.py:6:12 `fn` → on_save [asserted]" + ] + }, + { + "why": "path walks the asserted edge, labelled [asserted], never [known_edge]", + "run": [ + "path", + "test_save", + "audit" + ], + "want": [ + "[asserted · call @ app/dispatch.py:6] on_save", + "ASSERTED by a link", + "reached through resolved calls and 1 asserted link(s)", + "[asserted] ASSERTED by a link" + ], + "avoid": [ + "target(s) reached through resolved calls;" + ] + }, + { + "why": "impact lists the caller as asserted, and selects the test through it", + "run": [ + "impact", + "on_save", + "--tests" + ], + "want": [ + "[asserted] dispatch app/dispatch.py:6", + "test_dispatch.py::test_save", + "reaches those through resolved calls and 1 asserted link(s)" + ], + "avoid": [ + "[resolved] dispatch", + "axiomcode-links.tsv:" + ] + }, + { + "why": "a second target at the same site is a fan, each its own edge", + "run": [ + "link", + "app/dispatch.py:6", + "on_load" + ], + "want": [ + "linked app/dispatch.py:6:12 `fn` → on_load [asserted]" + ] + }, + { + "why": "CONTROL: a link to a declaration that does not exist is rejected", + "run": [ + "link", + "app/dispatch.py:6", + "no_such_handler" + ], + "expect_error": true, + "want": [ + "rejected — the target is not a declaration in this graph" + ] + }, + { + "why": "CONTROL: a link at a line with no call is rejected", + "run": [ + "link", + "app/dispatch.py:3", + "on_load" + ], + "expect_error": true, + "want": [ + "rejected — no call is written at app/dispatch.py:3" + ] + }, + { + "why": "CONTROL: a call that names another declaration cannot be linked to an unrelated target", + "run": [ + "link", + "app/dispatch.py:19", + "on_load" + ], + "expect_error": true, + "want": [ + "rejected — the call as written names `audit`" + ] + }, + { + "why": "CONTROL: a member call on an untyped receiver names a library method, not an unrelated function", + "run": [ + "link", + "app/dispatch.py:23", + "on_load" + ], + "expect_error": true, + "want": [ + "rejected — the call is to the member `copy` of a receiver the graph could not type" + ] + }, + { + "why": "CONTROL: an unlinked sibling site stays unknown", + "run": [ + "impact", + "purge", + "--unknown" + ], + "want": [ + "app/dispatch.py:15:12 return fn(doc) [computed_attribute_name:on_*]" + ], + "avoid": [ + "app/dispatch.py:15:12 return fn(doc) [computed_attribute_name:on_*] → linked" + ] + }, + { + "why": "CONTROL: the table call nobody linked still reaches nothing", + "run": [ + "path", + "test_table", + "on_load" + ], + "expect_error": true, + "want": [ + "no chain of resolved calls connects test_table and on_load" + ] + }, + { + "why": "lines inserted above the site: after the rebuild the link follows the line by its text", + "edit": [ + "app/dispatch.py", + "from app import handlers\n", + "from app import handlers\n# dispatch by event name\n\n" + ], + "run": [ + "path", + "test_save", + "audit", + "--fresh" + ], + "want": [ + "[asserted · call @ app/dispatch.py:8] on_save" + ] + }, + { + "why": "the list says where the link now is", + "run": [ + "link" + ], + "want": [ + "[moved] app/dispatch.py:6:12 (now line 8) → on_save" + ] + }, + { + "why": "the linked line itself edited: the list says so at once, before any rebuild (another function's identical line is not it)", + "edit": [ + "app/dispatch.py", + " return fn(doc)\n\n\ndef run_table", + " return fn(dict(doc))\n\n\ndef run_table" + ], + "run": [ + "link" + ], + "want": [ + "[changed] app/dispatch.py:6:12 (now line 8) → on_save" + ] + }, + { + "why": "…and after the rebuild it is dropped: the site is unknown again, and the answer says a link was not applied", + "run": [ + "path", + "test_save", + "audit", + "--fresh" + ], + "expect_error": true, + "want": [ + "no chain of resolved calls connects test_save and audit", + "links: 2 of 2 asserted link(s) not applied (2 stale)" + ], + "avoid": [ + "[asserted" + ] + }, + { + "why": "…and the list gives the reason", + "run": [ + "link" + ], + "want": [ + "[stale] app/dispatch.py:6:12 → on_save — stale: the line was edited (no line with its text and that call is left in dispatch)" + ] + } + ] +} diff --git a/tests/cases/python/asserted-links/tests/test_dispatch.py b/tests/cases/python/asserted-links/tests/test_dispatch.py new file mode 100644 index 00000000..cf1bafe0 --- /dev/null +++ b/tests/cases/python/asserted-links/tests/test_dispatch.py @@ -0,0 +1,10 @@ +from app.dispatch import dispatch, run_table + + +def test_save(): + assert dispatch("on_save", {})["audited"] + + +def test_table(): + from app import handlers + assert run_table({"load": handlers.on_load}, "load", {}) == {} diff --git a/tests/cases/typescript/asserted-links/case.json b/tests/cases/typescript/asserted-links/case.json new file mode 100644 index 00000000..c20d8668 --- /dev/null +++ b/tests/cases/typescript/asserted-links/case.json @@ -0,0 +1,169 @@ +{ + "lang": "typescript", + "src": "src", + "checks": [ + { + "why": "BASE: a call through a value read by a computed key is unresolved, so no chain reaches the handler it runs", + "run": [ + "path", + "dispatch", + "audit" + ], + "expect_error": true, + "want": [ + "no chain of resolved calls connects dispatch and audit" + ] + }, + { + "why": "the unresolved site and the call through a function-typed table are listed as work items", + "run": [ + "impact", + "dispatch", + "--unknown" + ], + "want": [ + "src/dispatch.ts:6:10 return fn(doc);" + ] + }, + { + "why": "a call through a holder of a function type is listed too: the signature has no body", + "run": [ + "impact", + "runTable", + "--unknown" + ], + "want": [ + "src/dispatch.ts:10:10 return table[key](doc); [calls a value typed by a function-type signature, not a body]" + ] + }, + { + "why": "link records the target of the call at that line", + "run": [ + "link", + "src/dispatch.ts:6", + "onSave" + ], + "want": [ + "linked src/dispatch.ts:6:10 `fn` → onSave [asserted]" + ] + }, + { + "why": "path walks the asserted edge, labelled [asserted]", + "run": [ + "path", + "dispatch", + "audit" + ], + "want": [ + "[asserted · call @ src/dispatch.ts:6] onSave", + "reached through resolved calls and 1 asserted link(s)", + "[asserted] ASSERTED by a link" + ], + "avoid": [ + "target(s) reached through resolved calls;" + ] + }, + { + "why": "impact lists the caller as asserted and selects the test through it", + "run": [ + "impact", + "onSave", + "--tests" + ], + "want": [ + "[asserted] dispatch", + "dispatch.test.ts", + "reaches those through resolved calls and 1 asserted link(s)" + ] + }, + { + "why": "CONTROL: a link to a declaration that does not exist is rejected", + "run": [ + "link", + "src/dispatch.ts:6", + "onArchive" + ], + "expect_error": true, + "want": [ + "rejected — the target is not a declaration in this graph" + ] + }, + { + "why": "CONTROL: a link at a line with no call is rejected", + "run": [ + "link", + "src/dispatch.ts:3", + "onLoad" + ], + "expect_error": true, + "want": [ + "rejected — no call is written at src/dispatch.ts:3" + ] + }, + { + "why": "CONTROL: a call that names another declaration cannot be linked to an unrelated target", + "run": [ + "link", + "src/dispatch.ts:19", + "onLoad" + ], + "expect_error": true, + "want": [ + "rejected — the call as written names `audit`" + ] + }, + { + "why": "CONTROL: an unlinked sibling site stays unknown", + "run": [ + "impact", + "purge", + "--unknown" + ], + "want": [ + "src/dispatch.ts:15:10 return fn(doc);" + ], + "avoid": [ + "→ linked" + ] + }, + { + "why": "lines inserted above the site: after the rebuild the link follows the line by its text", + "edit": [ + "src/dispatch.ts", + "import * as handlers from './handlers';\n", + "import * as handlers from './handlers';\n// dispatch by event name\n\n" + ], + "run": [ + "path", + "dispatch", + "audit", + "--fresh" + ], + "want": [ + "[asserted · call @ src/dispatch.ts:8] onSave" + ] + }, + { + "why": "the linked line itself edited: after the rebuild the link is dropped and the answer says so", + "edit": [ + "src/dispatch.ts", + " return fn(doc);\n}\n\nexport function runTable", + " return fn({ ...doc });\n}\n\nexport function runTable" + ], + "run": [ + "path", + "dispatch", + "audit", + "--fresh" + ], + "expect_error": true, + "want": [ + "no chain of resolved calls connects", + "links: 1 of 1 asserted link(s) not applied (1 stale)" + ], + "avoid": [ + "[asserted" + ] + } + ] +} diff --git a/tests/cases/typescript/asserted-links/src/dispatch.test.ts b/tests/cases/typescript/asserted-links/src/dispatch.test.ts new file mode 100644 index 00000000..4fe07e03 --- /dev/null +++ b/tests/cases/typescript/asserted-links/src/dispatch.test.ts @@ -0,0 +1,12 @@ +import { describe, it, expect } from 'vitest'; +import { dispatch, runTable } from './dispatch'; +import { onLoad } from './handlers'; + +describe('dispatch', () => { + it('saves', () => { + expect(dispatch('onSave', {}).audited).toBe(true); + }); + it('runs a table', () => { + expect(runTable({ load: onLoad }, 'load', {})).toEqual({}); + }); +}); diff --git a/tests/cases/typescript/asserted-links/src/dispatch.ts b/tests/cases/typescript/asserted-links/src/dispatch.ts new file mode 100644 index 00000000..e79fd43c --- /dev/null +++ b/tests/cases/typescript/asserted-links/src/dispatch.ts @@ -0,0 +1,20 @@ +import * as handlers from './handlers'; +type Doc = Record; + +export function dispatch(event: string, doc: Doc): Doc { + const fn = (handlers as any)[event]; + return fn(doc); +} + +export function runTable(table: Record Doc>, key: string, doc: Doc): Doc { + return table[key](doc); +} + +export function purge(doc: Doc): Doc { + const fn = (handlers as any)['on' + 'Purge']; + return fn(doc); +} + +export function saveAndAudit(doc: Doc): Doc { + return handlers.audit(doc); +} diff --git a/tests/cases/typescript/asserted-links/src/handlers.ts b/tests/cases/typescript/asserted-links/src/handlers.ts new file mode 100644 index 00000000..2d1a1e97 --- /dev/null +++ b/tests/cases/typescript/asserted-links/src/handlers.ts @@ -0,0 +1,18 @@ +type Doc = Record; + +export function onSave(doc: Doc): Doc { + return audit(doc); +} + +export function onLoad(doc: Doc): Doc { + return doc; +} + +export function onPurge(doc: Doc): Doc { + return doc; +} + +export function audit(doc: Doc): Doc { + doc.audited = true; + return doc; +} diff --git a/tests/front_door.py b/tests/front_door.py index 67566e8e..a281856d 100644 --- a/tests/front_door.py +++ b/tests/front_door.py @@ -12,7 +12,7 @@ path answer with numbered places and a fenced code block; after an edit, impact with no name starts with `your edits:`, and tests lists the test with its code and ends with a `run:` line. A verb off the surface is refused with the supported list. - b. the MCP server lists exactly context, impact, path and tests, each with at most two parameters, and a call to one + b. the MCP server lists exactly context, impact, path, tests and link, each with at most two parameters, and a call to one answers in the same shape. c. CONTROLS: the dispatcher run directly, bin/axiomcode with --json, and AXIOMCODE_RAW=1 give the old answer — no fenced block — for the same question. @@ -127,7 +127,7 @@ def main(): # ── b. the MCP server ────────────────────────────────────────────────────────────────────────────────────── got = mcp(repo, [('path', {'start': 'total', 'end': 'vat_rate'}), ('impact', {'name': 'vat_rate'})]) tools = {t['name']: list((t.get('inputSchema') or {}).get('properties', {})) for t in got.get(2, {}).get('tools', [])} - check('MCP tools/list is exactly context, impact, path and tests (search is grep\'s)', set(tools) == {'context', 'impact', 'path', 'tests'}, tools) + check('MCP tools/list is exactly context, impact, path, tests and link (search is grep\'s)', set(tools) == {'context', 'impact', 'path', 'tests', 'link'}, tools) check('MCP: every tool takes at most two parameters', bool(tools) and all(len(p) <= 2 for p in tools.values()), tools) text = lambda i: ''.join(c.get('text', '') for c in got.get(i, {}).get('content', [])) check('MCP path answers as numbered places with their code', places(text(3)), text(3)[:600]) diff --git a/tests/mcp.py b/tests/mcp.py index b6e43791..f85b37d3 100644 --- a/tests/mcp.py +++ b/tests/mcp.py @@ -32,7 +32,7 @@ # THE SMALL SURFACE: four questions, each with at most two parameters and no options. The front-door answer is capped # at ten places with the rest counted, so no tool is paged. context is the one narrative verb: a task in words, # answered as the verb's own flow rather than as places. -TOOLS = {'context': ['task', 'source'], 'impact': ['name'], 'path': ['start', 'end'], 'tests': []} +TOOLS = {'context': ['task', 'source'], 'impact': ['name'], 'path': ['start', 'end'], 'tests': [], 'link': ['site', 'target']} def exchange(cmd, cwd, env=None, workdir=None): diff --git a/tests/run.py b/tests/run.py index 772ba182..fe077e91 100755 --- a/tests/run.py +++ b/tests/run.py @@ -22,6 +22,8 @@ CONCATENATED, so no substring can express "this must not be inside the document" — which is how a `note:` line sat in --json for every name declared as both a field and a method. "expect_error": true a non-zero exit is the answer, not a fault (`path` exits 1 when it finds no chain). + "edit": [file, old, new] the case edits that file before the check runs; edited files and a links file the case + wrote are restored when the case ends. "pending": "" the check states behaviour the tool does NOT have yet. It still RUNS. Failing prints PEND and is not a suite failure; PASSING is a failure reading "remove the marker", so a gap that closes cannot keep a marker claiming it is open. @@ -57,8 +59,17 @@ def print(*a, flush=False, **k): builtins.print(*a, file=buf, **k) if r.returncode: print(f"FAIL {l}/{name}: index failed: {(r.stderr or r.stdout)[-300:]}"); return buf.getvalue(), 0, 1, 0 for stmt in spec.get('sql', []): # facts a framework extension would have written subprocess.run(['sqlite3', os.path.join(path, '.axiomcode', 'out', 'graph.sqlite'), stmt], capture_output=True, text=True) + # "edit": [file, old, new] — the case edits one of its files before that check runs (an asserted link whose line moved + # or changed); every edited file, and a links file the case wrote, is put back when the case ends + links_file = os.path.join(path, 'axiomcode-links.tsv'); links_had = open(links_file).read() if os.path.exists(links_file) else None + edited = {} for ch in spec['checks']: tot += 1 + if ch.get('edit'): + ef, old, new = ch['edit']; fp = os.path.join(path, ef); txt = open(fp).read() + edited.setdefault(fp, txt) + if old not in txt: print(f"FAIL {l}/{name}: the edit's old text is not in {ef}"); fail += 1; continue + open(fp, 'w').write(txt.replace(old, new, 1)) out = subprocess.run(['bash', AX] + [a.replace('{repo}', path) for a in ch['run']] + ([path] if ch['run'][0] != 'index' else []), capture_output=True, text=True) text = out.stdout + out.stderr # a [text] row quoting this case.json is the spec read back (a name no graph declares is searched as text, and the @@ -101,6 +112,10 @@ def print(*a, flush=False, **k): builtins.print(*a, file=buf, **k) # a traceback's last line is the error itself: keep the head (what it answered) and the tail (why it stopped) print(' ' + '\n '.join(lines[:14] + (['…'] + lines[-12:] if len(lines) > 26 else lines[14:]))) elif verbose: print(f"ok {l}/{name}: {ch['why']}") + for fp, txt in edited.items(): open(fp, 'w').write(txt) + if links_had is None: + if os.path.exists(links_file): os.remove(links_file) + else: open(links_file, 'w').write(links_had) if not keep: shutil.rmtree(os.path.join(path, '.axiomcode'), ignore_errors=True) return buf.getvalue(), tot, fail, pend diff --git a/tests/surfaces.py b/tests/surfaces.py index ea2e81f7..c4eb20ab 100644 --- a/tests/surfaces.py +++ b/tests/surfaces.py @@ -29,7 +29,7 @@ MCP = os.path.join(PLUG, 'mcp', 'server.py') CLI = os.path.join(ROOT, 'bin', 'axiomcode') # the command an install puts on $PATH -PUBLIC = ['index', 'impact', 'path', 'tests', 'context'] +PUBLIC = ['index', 'impact', 'path', 'tests', 'context', 'link'] NO_MCP = {'index': 'setup, not a question: the first query through the MCP server builds the graph itself'} # dispatched, not advertised: verb -> why INTERNAL = { From 5a39161c03788243249614df8dcddf96e9592fa8 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:17:35 -0700 Subject: [PATCH 08/11] javascript: a test that never loads the change is not selected; a mocha load-time throw selects the whole run impact - imports_fact resolves a directory import (`require('..')`, `require('../')`, a folder with a package.json main/exports), the repository's own package name, a side-effect `import './x'` and a literal `import('./x')`; a package whose entry is a build output the checkout lacks names its src/ (or lib/) entry. imports_dynamic records the files that load a module by a computed name. - loads_change asks whether the test loads the asked declaration's FILE, not the end of the shortest route (which can run through another test file's subclass or callback); it says nothing where the target file has no importer, the test file imports nothing here, or the test's import closure holds a computed-name loader. - a JavaScript test whose route is in-process (sound, one of a set, dispatch, registered, defines, fixture) and whose file never loads the changed file is kept apart as `not_loading_tests` and named on a `not counted:` line, never selected: a plugin's prototype override is credited only to the tests that load the plugin, and a callback one test file hands to a shared function no longer pulls in every other caller's tests. Key joins, remote hops and spawns are unaffected. - load_run (impact.dl): test-code declarations that reach the change through calls alone; a test credited through its module body or describe() block that is one of them carries `at_load`. path - from a JavaScript test file that never loads the target's file: "no route: X never loads Y". test-impact - a selected JavaScript file no runner collects is replaced by the collected test files that require it, or moved to not_collected when none does (unless a test file loads modules by a computed name). - a mocha run whose specs reach the change at load time (`at_load`) lists every spec that run collects: mocha loads them in one process and a throw there fails all of them. jest/vitest are per-file and unchanged. - a changed function-constructor (`function Foo(opts) {` header edit is a TYPE to `changed`) is asked again as the function when impact finds no type at that line. Measured on 8 JavaScript subjects against cached mutation truth (no-library protocol), base -> this: impact R 0.522 -> 0.542, P 0.238 -> 0.295; tests R 0.494 -> 0.559, P 0.264 -> 0.310; held-out impact P 0.156 -> 0.478 at equal recall; no failing (test file, target) pair lost in impact, tests or path on any subject. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../skills/axiomcode/scripts/axiomcode-impact | 150 +++++++++++++++--- .../skills/axiomcode/scripts/axiomcode-path | 39 +++++ .../axiomcode/scripts/axiomcode-test-impact | 91 ++++++++++- .../skills/axiomcode/scripts/dl/impact.dl | 13 ++ .../case.json | 110 +++++++++++++ .../src/index.js | 1 + .../src/lib/audit.js | 5 + .../src/lib/core.js | 13 ++ .../src/package.json | 1 + .../src/plugins/upper.js | 7 + .../src/test/audited.test.js | 6 + .../src/test/by-name.test.js | 7 + .../src/test/later.test.js | 5 + .../src/test/plain.test.js | 5 + .../src/test/upper.test.js | 8 + .../case.json | 89 +++++++++++ .../edits/ctor-new.txt | 9 ++ .../edits/ctor-old.txt | 9 ++ .../jestpkg/lib/build.js | 5 + .../jestpkg/package.json | 1 + .../jestpkg/test/a.test.js | 8 + .../jestpkg/test/b.test.js | 3 + .../lib/build.js | 5 + .../lib/ctor.js | 9 ++ .../lib/fmt.js | 5 + .../lib/parse.js | 5 + .../package.json | 2 + .../test/a.spec.js | 9 ++ .../test/b.spec.js | 9 ++ .../test/bench.js | 5 + .../test/c.spec.js | 8 + .../test/d.spec.js | 8 + .../test/parts/extra.js | 6 + 33 files changed, 631 insertions(+), 25 deletions(-) create mode 100644 tests/cases/javascript/a-test-that-never-loads-the-change/case.json create mode 100644 tests/cases/javascript/a-test-that-never-loads-the-change/src/index.js create mode 100644 tests/cases/javascript/a-test-that-never-loads-the-change/src/lib/audit.js create mode 100644 tests/cases/javascript/a-test-that-never-loads-the-change/src/lib/core.js create mode 100644 tests/cases/javascript/a-test-that-never-loads-the-change/src/package.json create mode 100644 tests/cases/javascript/a-test-that-never-loads-the-change/src/plugins/upper.js create mode 100644 tests/cases/javascript/a-test-that-never-loads-the-change/src/test/audited.test.js create mode 100644 tests/cases/javascript/a-test-that-never-loads-the-change/src/test/by-name.test.js create mode 100644 tests/cases/javascript/a-test-that-never-loads-the-change/src/test/later.test.js create mode 100644 tests/cases/javascript/a-test-that-never-loads-the-change/src/test/plain.test.js create mode 100644 tests/cases/javascript/a-test-that-never-loads-the-change/src/test/upper.test.js create mode 100644 tests/cases/javascript/mocha-load-time-fails-the-whole-run/case.json create mode 100644 tests/cases/javascript/mocha-load-time-fails-the-whole-run/edits/ctor-new.txt create mode 100644 tests/cases/javascript/mocha-load-time-fails-the-whole-run/edits/ctor-old.txt create mode 100644 tests/cases/javascript/mocha-load-time-fails-the-whole-run/jestpkg/lib/build.js create mode 100644 tests/cases/javascript/mocha-load-time-fails-the-whole-run/jestpkg/package.json create mode 100644 tests/cases/javascript/mocha-load-time-fails-the-whole-run/jestpkg/test/a.test.js create mode 100644 tests/cases/javascript/mocha-load-time-fails-the-whole-run/jestpkg/test/b.test.js create mode 100644 tests/cases/javascript/mocha-load-time-fails-the-whole-run/lib/build.js create mode 100644 tests/cases/javascript/mocha-load-time-fails-the-whole-run/lib/ctor.js create mode 100644 tests/cases/javascript/mocha-load-time-fails-the-whole-run/lib/fmt.js create mode 100644 tests/cases/javascript/mocha-load-time-fails-the-whole-run/lib/parse.js create mode 100644 tests/cases/javascript/mocha-load-time-fails-the-whole-run/package.json create mode 100644 tests/cases/javascript/mocha-load-time-fails-the-whole-run/test/a.spec.js create mode 100644 tests/cases/javascript/mocha-load-time-fails-the-whole-run/test/b.spec.js create mode 100644 tests/cases/javascript/mocha-load-time-fails-the-whole-run/test/bench.js create mode 100644 tests/cases/javascript/mocha-load-time-fails-the-whole-run/test/c.spec.js create mode 100644 tests/cases/javascript/mocha-load-time-fails-the-whole-run/test/d.spec.js create mode 100644 tests/cases/javascript/mocha-load-time-fails-the-whole-run/test/parts/extra.js diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index dfc15d5b..b1376325 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -799,7 +799,9 @@ class Impact: # REPOSITORY is kept: a stdlib or third-party import names no file here and is dropped rather than guessed. IMPORT_RE = { '.py': re.compile(r'^\s*(?:from\s+(\.*[\w.]*)\s+import|import\s+([\w.]+))', re.M), - '.ts': re.compile(r"""(?:^\s*import\b[^'"\n]*from\s*|^\s*export\b[^'"\n]*from\s*|\brequire\s*\(\s*)['"]([^'"]+)['"]""", re.M), + # `import x from`, `export … from`, `require(`, and the two forms that load a module for its effect or later: + # `import './locale/fr'` (a side-effect import names no binding, so it has no `from`) and `import('./x')` + '.ts': re.compile(r"""(?:^\s*import\b[^'"\n]*from\s*|^\s*export\b[^'"\n]*from\s*|^\s*import\s*|\brequire\s*\(\s*|\bimport\s*\(\s*)['"]([^'"]+)['"]""", re.M), } IMPORT_RE['.tsx'] = IMPORT_RE['.js'] = IMPORT_RE['.jsx'] = IMPORT_RE['.mjs'] = IMPORT_RE['.cjs'] = IMPORT_RE['.ts'] @@ -826,6 +828,9 @@ class Impact: if n.endswith('/__init__'): mod.setdefault(n[:-len('/__init__')].replace('/', '.'), f) out = set() + # a JavaScript file that loads a module by a COMPUTED specifier (`require(path.join(dir, file))`, `import(name)`): + # what it loads is not in these facts, so a closure through it is not complete (imports_dynamic) + self._dyn_import = set() # pytest imports every conftest.py from the rootdir down to a test file's directory before the file itself, # so whatever a conftest imports is imported for each test file beneath it conftests = [f for f in files if f.rsplit('/', 1)[-1] == 'conftest.py'] @@ -844,6 +849,7 @@ class Impact: if not rx: continue try: text = '\n'.join(self.lines(f)) except Exception: continue + if ext != '.py' and self.DYN_IMPORT.search(text): self._dyn_import.add(f) for m in rx.finditer(text): raw = next((x for x in m.groups() if x), '') if not raw: continue @@ -866,12 +872,40 @@ class Impact: if tgt and tgt != f: out.add((f, tgt)) return sorted(out) + DYN_IMPORT = re.compile(r"""(? 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 = '69' # 69: imports_fact resolves a JavaScript directory import (`require('..')`, a folder with a package.json); 68: both 67s below, from two branches; 67: spawns_fact also links the module a `python -m pkg.mod` spawn runs; 67: libcb, a library summarised to call a client member back (Python library-callbacks.dl), walked as fw_edge "library callback"; 66: imports_fact names a module from its package root (a src layout) and has each test file import the conftest.py files above it; 65: guard_only also reads a conjunctive guard (`cond and __name__ == "__main__"`); 64: guard_only, a module body's call made only under `if __name__ == "__main__":`, which the import walk does not take; 63: imports_fact resolves an import written with its extension and a workspace package's specifier, and test_method takes no JavaScript / TypeScript helper by its name; 62: state_gate, state_gate_alloc, state_call_alloc, state_call_open, state_world, the callbacks one instance was given and the allocation each caller's receiver may be (JavaScript instance-state.dl); 61: filter_run, a servlet filter a test's context holds (added to HttpSecurity in a configuration the test loads, or a component) and the test methods that send a request through it; 57: a TypeScript object literal key is a ref of entity kind OBJECT_PROPERTY_KEY, kept past a bound access on its line; 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 @@ -1402,7 +1448,7 @@ class Impact: if stack: lex.append((i, stack[-1][2])) stack.append((a, b, i)) W('lex_parent', sorted(set(lex))) - W('imports_fact', self.imports()) + W('imports_fact', self.imports()); W('imports_dynamic', sorted((f,) for f in self._dyn_import)) # a servlet filter a test's context holds runs on every request that test sends (ax_registration.filter_links) W('filter_run', sorted(set(ax_registration.filter_links(g.q)))) # a test that runs a script as a child process by its path: the path is the only link (ax_spawn.py) @@ -2215,7 +2261,7 @@ class Impact: prof('query facts written'); r = subprocess.run(prog + ['-F', F, '-D', O], capture_output=True, text=True); prof('souffle returned') if r.returncode: die("souffle failed:\n" + r.stderr[-1200:]) out = {} - for n in ('contract', 'direct', 'direct_edge', 'seed', 'seed_byname', 'reach', 'reach_sure', 'parent_up', 'test_near', 'test_hit', 'test_stub', 'inherited_test', 'extbind', 'gen_fired', 'caller_handles', 'caller_unhandled', 'target_throws'): + for n in ('contract', 'direct', 'direct_edge', 'seed', 'seed_byname', 'reach', 'reach_sure', 'parent_up', 'test_near', 'test_hit', 'test_stub', 'inherited_test', 'extbind', 'gen_fired', 'caller_handles', 'caller_unhandled', 'target_throws', 'load_run'): rows = [] for l in open(os.path.join(O, n + '.csv')): p = l.rstrip('\n').split('\t') @@ -2866,29 +2912,39 @@ def main(argv): # resolved to a table in a service the test never starts -- is a lead further out, not a test of the change: on # one monorepo 19 test files were selected for a handler table's module and 2 of them import it. None where the # language has no import graph here (imports_fact covers Python, JavaScript and TypeScript). - _rev = None; _importers = {} + _rev = None; _importers = {}; _fwd = None; _dyn = set() + # THE FILE ASKED ABOUT, not the end of the nearest route. That route is the shortest one, and the closure is a + # union over every caller: a test reaches `Option`'s constructor through a subclass ANOTHER test file declares + # (`class MyOption extends Option`), whose file it never loads, while the route it really takes is one hop + # longer. Read off the route's end, the test was said never to load the change; read off the asked declaration's + # own file, it loads it through the package entry like every other test. + tgt_files = {g.sym[x]['file'] for _k, _lab, pay in targets if isinstance(pay, list) for x in pay + if isinstance(x, str) and x in g.sym and g.sym[x].get('file')} def loads_change(m, fx): - nonlocal _rev + nonlocal _rev, _fwd tf = g.sym[m].get('file') or '' route = test_route(m, fx) seed_file = (g.sym.get(route[-1]) or {}).get('file') if route else None if not tf or not seed_file or os.path.splitext(tf)[1].lower() not in I.IMPORT_RE: return None - if tf == seed_file or test_cert.get(m) in ('spawns', 'at import'): return True + if tf == seed_file or tf in tgt_files or test_cert.get(m) in ('spawns', 'at import'): return True if _rev is None: - _rev = collections.defaultdict(set) + _rev = collections.defaultdict(set); _fwd = collections.defaultdict(set) try: for ln in open(os.path.join(g.facts, 'impact', 'imports_fact.facts'), encoding='utf-8', errors='replace'): a, _, b = ln.rstrip('\n').partition('\t') - if a and b: _rev[b].add(a) + if a and b: _rev[b].add(a); _fwd[a].add(b) + _dyn.update(ln.rstrip('\n') for ln in open(os.path.join(g.facts, 'impact', 'imports_dynamic.facts'), encoding='utf-8', errors='replace')) except OSError: _rev = {} # no import facts: nothing can be said either way if not _rev: return None - if seed_file not in _importers: - seen, todo = {seed_file}, [seed_file] - while todo: - for a in _rev.get(todo.pop(), ()): - if a not in seen: seen.add(a); todo.append(a) - _importers[seed_file] = seen + def importers(f): + if f not in _importers: + seen, todo = {f}, [f] + while todo: + for a in _rev.get(todo.pop(), ()): + if a not in seen: seen.add(a); todo.append(a) + _importers[f] = seen + return _importers[f] loaders = {tf} | ({g.sym[fx].get('file')} if fx and fx in g.sym else set()) if tf.endswith('.py'): d_ = os.path.dirname(tf) @@ -2896,7 +2952,18 @@ def main(argv): loaders.add(f"{d_}/conftest.py" if d_ else 'conftest.py') if not d_: break d_ = os.path.dirname(d_) - return bool(loaders & _importers[seed_file]) + files_ = tgt_files or {seed_file} + # a file NOTHING here imports is loaded some other way (a plugin required by a computed name, a bundle a + # runner config names), and a test file that imports nothing here reads the package through a path alias + # this scan does not resolve: in both the import graph cannot say, so it says nothing + if not any(len(importers(f)) > 1 for f in files_) or not any(_fwd.get(x) for x in loaders): return None + # and where the test, or a file it loads, loads a module by a computed name, it may load the changed one + seen, todo = set(loaders), list(loaders) + while todo: + for y in _fwd.get(todo.pop(), ()): + if y not in seen: seen.add(y); todo.append(y) + if seen & _dyn: return None + return any(loaders & importers(f) for f in files_) # a MODULE counted as a test is a script test (graph_sql.script_tests): it is labelled with the command it runs by script_ids = {m for m in tests if g.sym[m]['kind'] == 'module'} _runs = {} @@ -3069,6 +3136,35 @@ def main(argv): if CERT_RANK[c] > CERT_RANK[worst]: worst = c return worst test_cert = {m: cert_of(m) for m in tests} + # RUNS WHILE THE FILE LOADS: the route is an import (`at import`), or the test is credited through its file's module + # body or the describe() block around it and that block reaches the change through calls alone (load_run), so the + # change runs while the runner loads the file, before any test + load_run = {x[0] for x in res.get('load_run', [])} + _DESCRIBE = re.compile(r'(? 4 else '')) + if not_loading: + nlf = sorted({g.sym[m]['file'] for m in not_loading}) + print(f" not counted: {len(nlf)} test file(s) reach it only through code they never load — none of their imports, followed" + " through the project, imports the changed file (a callback another test file hands over, a plugin they never import): " + + ', '.join(nlf[:4]) + (f" … +{len(nlf) - 4}" if len(nlf) > 4 else '')) # A PROGRAM THE SUITES START AS A SUBPROCESS (a CLI script, a hook, a subcommand its dispatcher execs): no test # calls it, so the count is 0, and a reader took that as untested and ran nothing if not tests: diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path index 4fcb51ae..1337b01d 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path @@ -1885,10 +1885,49 @@ def grep_for(g, name): """the exact search that finds what the graph could not: the name as a whole word in the source roots""" return f'grep -rnw "{name}" {grep_roots(g)}' +# ── a JavaScript test file that never loads the target ──────────────────────────────────────────────────────────── +JS_EXT = ('.js', '.mjs', '.cjs', '.jsx') +def never_loads(g, A, B): + """(test file, target files) when every start is in ONE JavaScript test file whose imports, followed through the + project, never reach a file the target is declared in; else None. Such a file cannot run the target, whatever route + the call graph has: the route goes through a callable another test file declares (a callback handed to a shared + function) or an override a plugin installs only where it is loaded. Read from impact's import facts + (imports_fact), only when they were written for this graph; where either end has no import at all the scan cannot + say (a path alias, a file loaded by a computed name), and nothing is gated. impact drops the same tests the same way.""" + fa = {g.sym[m].get('file') for m in A if m in g.sym} + fb = {g.sym[m].get('file') for m in B if m in g.sym} - {None, '', ''} + if len(fa) != 1 or not fb: return None + fa = next(iter(fa)) + if not fa or not fa.endswith(JS_EXT) or fa in fb or not all(g.sym[m].get('is_test') for m in A if m in g.sym): return None + D = os.path.join(g.facts, 'impact') + try: + if not open(os.path.join(D, 'stamp')).read().startswith(f"{g.db_mtime}:"): return None + fwd, rev = collections.defaultdict(set), collections.defaultdict(set) + for ln in open(os.path.join(D, 'imports_fact.facts'), encoding='utf-8', errors='replace'): + x, _, y = ln.rstrip('\n').partition('\t') + if x and y: fwd[x].add(y); rev[y].add(x) + dyn = {ln.rstrip('\n') for ln in open(os.path.join(D, 'imports_dynamic.facts'), encoding='utf-8', errors='replace')} + except OSError: + return None + if not fwd.get(fa) or not all(rev.get(f) for f in fb): return None + seen, todo = {fa}, [fa] + while todo: + for y in fwd.get(todo.pop(), ()): + if y not in seen: seen.add(y); todo.append(y) + # a file on the way that loads a module by a computed name (`require(path.join(dir, f))`) may load the target + return (fa, sorted(fb)) if not (seen & fb) and not (seen & dyn) else None + # ── the query──────────────────────────────────────────────────────────────────────────────────────────────────── def path(g, a, b, show_all=False, limit=10, every=False, max_paths=20): g.export() la, A = g.resolve(a); lb, B = g.resolve(b) + nl = never_loads(g, A, B) + if nl: + RESULT['never_loads'] = {'test_file': nl[0], 'target_files': nl[1]} + print(f"no route: {nl[0]} never loads {', '.join(nl[1])} — none of the files it imports, followed through the project," + f" imports it, so no run of that test executes {lb}. The call graph's routes between them go through code that" + f" file never loads (a callback another test file hands to a shared function, an override a plugin installs).") + return 1 g.add_outside_call_edges() res = run(g, {'fwd': (A, B), 'rev': (B, A)}, ('path.dl', 'path-every.dl') if every else ('path.dl',)) if not res['hit']['fwd'] and not res['hit']['rev']: # nothing resolved connects them: now, and only now, the by-name closure diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact index 29e9f97e..2d29b424 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact @@ -1425,12 +1425,12 @@ def _script_for(repo, pkg, prel): return None -def js_plan(repo, files): +def js_plan(repo, files, cap=40): """(commands, files no runner collects): each file with the runner its own package would run it with.""" by_runner = collections.OrderedDict() singles, unrun = [], [] repo = os.path.realpath(repo) - for f in sorted(files)[:40]: + for f in (sorted(files)[:cap] if cap else sorted(files)): # the nearest directory whose runner collects the file runs it: a member's own config first, then the # root's (a workspace member with no runner of its own is run by the root's, with paths from the root) hit, any_pkg, d = None, False, os.path.dirname(f) @@ -1990,6 +1990,10 @@ def main(argv): # say it back, not a declaration the graph lacks. `Owner.m(p)` still refused is asked as `Owner.m` (the tests # that reach the method are the ones a parameter edit can fail); a batch still refused is asked one by one, so # one bad target does not cost the others their tests + # a JavaScript function that is also a constructor (`function MarkdownIt(opts)`, `function Route(path)`) is a type + # to `changed` and has no type declaration impact can name at that line: it is asked as the function it is + if rc != 0 and tk == 'type': + rc, o, e = run('axiomcode-impact', batch + [repo, '--tests', '--json'] + scope) if rc != 0 and len(batch) == 1 and '(' in batch[0]: bare = re.sub(r'\(.*\)$', '', batch[0]) rc, o, e = run('axiomcode-impact', [bare, repo, '--tests', '--json', '--kind', 'method'] + scope) @@ -2067,6 +2071,74 @@ def main(argv): ownerless = [r for r in tests.values() if not r.get('owner') and r.get('at') and not r.get('script')] # a script is its file run_files = sorted(set(files) | set(edited)) lang = (changed.get('lang') or '').lower() or _guess_lang(run_files or changed_files) + # A SELECTED JAVASCRIPT FILE NO RUNNER COLLECTS is not a test run: a benchmark or interop script under test/ that no + # include glob or package script names (`test/autobahn.js` beside `test/*.test.js`), or a file the runner only + # reaches through another test file that requires it (`require('./es2017/asyncFunctions.js')`). It is replaced by + # the collected test files that import it, through the project's imports, and where none does it moves to + # `not_collected` and out of the selection. Where a test file loads modules by a computed name + # (`require(path.join(dir, f))`) nothing can be said, and the file stays. + rehomed = {} + if lang == 'javascript' and (files or further): + sel = sorted(set(files) | set(further)) + unrun = [f for f in js_plan(repo, sel, cap=None)[1] if f.endswith(('.js', '.mjs', '.cjs', '.jsx'))] + if unrun: + D = os.path.join(os.environ.get('AXIOMCODE_GRAPH') or ax_fresh.graph_dir(repo_), 'out', 'dl', 'impact') + rev = collections.defaultdict(set); dyn = None + try: + for ln in open(os.path.join(D, 'imports_fact.facts'), encoding='utf-8', errors='replace'): + a, _, b = ln.rstrip('\n').partition('\t') + if a and b: rev[b].add(a) + dyn = {ln.rstrip('\n') for ln in open(os.path.join(D, 'imports_dynamic.facts'), encoding='utf-8', errors='replace')} + except OSError: + dyn = None # no import facts: say nothing, move nothing + # a collected test file that loads modules by a computed name (`fs.readdirSync(dir).forEach(f => require(f))`) + # may be what runs a file nothing requires by its path: such a file stays + dyn_runner = dyn is not None and any(not js_plan(repo, [x], cap=None)[1] and graph_sql.TEST_REGISTRAR.search(_read(os.path.join(repo, x), 400_000) or '') + for x in sorted(dyn) if x.endswith(('.js', '.mjs', '.cjs', '.jsx'))) + if dyn is not None: + for f in unrun: + seen, todo = {f}, [f] + while todo: + for a in rev.get(todo.pop(), ()): + if a not in seen: seen.add(a); todo.append(a) + if seen & dyn: continue + ups = sorted(seen - {f}) + if not ups and dyn_runner: continue + runs = [u for u in ups if not js_plan(repo, [u], cap=None)[1] and graph_sql.TEST_REGISTRAR.search(_read(os.path.join(repo, u), 400_000) or '')] + rehomed[f] = runs + if rehomed: + new_near = [x for x in files if x not in rehomed] + [r for f in files if f in rehomed for r in rehomed[f]] + new_far = [x for x in further if x not in rehomed] + [r for f in further if f in rehomed for r in rehomed[f]] + files = sorted(set(new_near)); further = sorted(set(new_far) - set(files)) + run_files = sorted(set(files) | set(edited)) + # A CHANGE THAT RUNS WHILE MOCHA LOADS THE SPECS FAILS THE WHOLE RUN. mocha requires every spec file, and runs every + # describe() body, in ONE process before it runs a single test; a throw there aborts the run and every spec file + # in it fails, the ones that never touch the change included. A test reached at load time -- through its file's + # module body (an `at import` route, or the module as the route) or through the describe() block around it -- is + # therefore every file that mocha run collects, not just its own. jest and vitest load each file in its own + # module registry, where the same throw fails that file alone, so only a mocha run is widened. + load_wide = {} + if lang == 'javascript' and tests: + for f in sorted({(r.get('at') or '').rpartition(':')[0] for r in tests.values() if r.get('at_load')} - {''}): + d = os.path.dirname(f); hit = None + while hit is None: + drel = f[len(d) + 1:] if d else f + hit = next(((d, name, collects) for name, collects, _c in _runners_of(repo, d) if collects(drel)), None) + if not d: break + d = os.path.dirname(d) + if not hit or hit[1] != 'mocha' or hit[0] in load_wide: continue + base = os.path.join(repo, hit[0]) if hit[0] else repo + run_list = [] + for root, dirs, fs in os.walk(base): + dirs[:] = [x for x in dirs if x not in ('node_modules', '.git', '.axiomcode', 'dist', 'coverage') and not x.startswith('.')] + for x in fs: + rel = os.path.relpath(os.path.join(root, x), base).replace(os.sep, '/') + if x.endswith(('.js', '.mjs', '.cjs', '.jsx', '.ts')) and hit[2](rel): + run_list.append(f"{hit[0]}/{rel}" if hit[0] else rel) + if len(run_list) > 1: load_wide[hit[0]] = sorted(run_list) + if load_wide: + files = sorted(set(files) | {x for v in load_wide.values() for x in v}); further = sorted(set(further) - set(files)) + run_files = sorted(set(files) | set(edited)) # the graph `impact` answered from, asked again for which selected classes are abstract (command_for) db = os.path.join(os.environ.get('AXIOMCODE_GRAPH') or ax_fresh.graph_dir(repo_), 'out', 'graph.sqlite') # the classes the command names: every owner, and a test that has none by its file (a class that inherits a @@ -2090,7 +2162,9 @@ def main(argv): 'edited_test_files': edited, 'command': cmd_all, 'abstract_replaced': replaced, - 'not_collected': js_plan(repo, run_files)[1] if lang in ('typescript', 'javascript') else [], + 'not_collected': sorted(set(js_plan(repo, run_files)[1] if lang in ('typescript', 'javascript') else []) | set(rehomed)), + **({'run_by_importer': {f: r for f, r in rehomed.items() if r}} if any(rehomed.values()) else {}), + **({'whole_run_at_load': {d or '.': len(v) for d, v in load_wide.items()}} if load_wide else {}), 'not_reached_from': unseen, 'framework_entries': list(fw_entries.values()), 'framework_grep': fw_greps, 'stub_only_not_selected': [dict(r, stubs=sorted(stub_by[i])) for i, r in stub_only.items() if i not in tests], @@ -2195,6 +2269,17 @@ def main(argv): if cmd_all: print("\n " + cmd_all.replace("\n", "\n ")) print_unrun(lang, repo, run_files) + for d_, v_ in sorted(load_wide.items()): + print(f"\n the change runs while mocha loads the specs (a module body or a describe() block reaches it), and a throw" + f" there fails the whole run: every one of the {len(v_)} spec file(s) mocha collects in {d_ or '.'} is listed") + moved = {f: r for f, r in rehomed.items() if r}; gone = sorted(f for f, r in rehomed.items() if not r) + if moved: + print(f"\n {len(moved)} file(s) the graph reached are run only through the test files that require them, which are" + " listed instead: " + '; '.join(f"{f} <- {', '.join(r[:2])}" + (f" +{len(r) - 2}" if len(r) > 2 else '') for f, r in sorted(moved.items())[:4]) + + (f" … +{len(moved) - 4}" if len(moved) > 4 else '')) + if gone: + print(f"\n not selected: {len(gone)} file(s) under the tests reach it but no runner collects them and no test file requires" + " them (a script or benchmark beside the tests): " + ', '.join(gone[:4]) + (f" … +{len(gone) - 4}" if len(gone) > 4 else '')) if cmd_further: print("\n then, further out: " + cmd_further.replace("\n", "\n ")) elif edited and cmd_all: diff --git a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl index 6c5f1978..efc23990 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl +++ b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl @@ -957,6 +957,19 @@ up(q, a, d+1) :- up_running(q, b, d), import_hop(q, a, b), d < 40. parent_up(q, a, b, "at import") :- reach(q, a, d), d > 0, reach(q, b, d1), d1 = d - 1, import_hop(q, a, b). .output reach .output parent_up +// WHAT RUNS WHILE A TEST FILE LOADS: a declaration in test code that reaches the change through calls alone (a resolved +// call or one of a resolved set; never `defines`, never a hand-over, which runs later or not at all), so a module body +// or a describe() block that is one of these runs the change before any test does. A mocha run requires every spec and +// runs every describe() in one process, and a throw there fails the whole run (axiomcode-test-impact reads this as +// `at_load` on the test it credits). Walked only as far as test code: the first test-file declaration ends the walk. +.decl load_up(q:symbol, m:symbol) +load_up(q, m) :- seed(q, m). +load_up(q, a) :- load_up(q, b), !load_stop(b), edge(a, b, t), (t = "known_edge" ; t = "multi_inferred"), !state_gate(a, b, _). +.decl load_stop(m:symbol) +load_stop(m) :- decl_file(m, f), is_test_file(f), !kind(m, "module"). +.decl load_run(q:symbol, m:symbol) +load_run(q, m) :- load_up(q, m), decl_file(m, f), is_test_file(f). +.output load_run // the same closure from the seeds that are an exact edge only: what is reached ONLY through a [by name] / [text] / // [one of a set] dependent is everything outside it, and the answer says how much of itself rests on those diff --git a/tests/cases/javascript/a-test-that-never-loads-the-change/case.json b/tests/cases/javascript/a-test-that-never-loads-the-change/case.json new file mode 100644 index 00000000..3478cd58 --- /dev/null +++ b/tests/cases/javascript/a-test-that-never-loads-the-change/case.json @@ -0,0 +1,110 @@ +{ + "lang": "javascript", + "src": "src", + "checks": [ + { + "why": "a plugin's override of a prototype method runs only where the plugin is loaded: the test that loads it is a test of it, the one that never imports the plugin file is not, though the receiver's dispatch set holds the override for every caller", + "run": [ + "impact", + "plugins/upper.js:4", + "--tests-only" + ], + "want": [ + "upper.test.js (", + "not counted: 1 test file(s)", + "plain.test.js" + ], + "avoid": [ + "plain.test.js (", + "later.test.js", + "audited.test.js" + ] + }, + { + "why": "a callback one test file hands to a shared function is not run by another test file that calls the same function: later.test.js never loads lib/audit.js", + "run": [ + "impact", + "audit", + "--tests-only" + ], + "want": [ + "audited.test.js (", + "not counted: 1 test file(s)" + ], + "avoid": [ + "later.test.js (" + ] + }, + { + "why": "CONTROL: a test that loads a module by a computed name (`require(path.join(...))`) may load the plugin, so it stays", + "run": [ + "impact", + "plugins/upper.js:4", + "--tests-only" + ], + "want": [ + "by-name.test.js (" + ] + }, + { + "why": "CONTROL: `require('..')`, `require('../')` and `require('../index.js')` load the package root's main file, so a test of the core class through any of them loads lib/core.js and stays selected", + "run": [ + "impact", + "Fmt.render", + "--tests-only" + ], + "want": [ + "plain.test.js (", + "upper.test.js (", + "by-name.test.js (" + ], + "avoid": [ + "not counted" + ] + }, + { + "why": "CONTROL: a test that imports the package by its own name (`require('fmtlib')`) loads its main file too", + "run": [ + "impact", + "later", + "--tests-only" + ], + "want": [ + "audited.test.js (", + "later.test.js (" + ], + "avoid": [ + "not counted" + ] + }, + { + "why": "path from a test file that never loads the target's file says so instead of printing a route through another test file's callback", + "run": [ + "path", + "test/later.test.js", + "audit" + ], + "want": [ + "never loads src/lib/audit.js" + ], + "avoid": [ + "call(s):" + ], + "expect_error": true + }, + { + "why": "CONTROL: path from the test that loads the plugin still prints its route", + "run": [ + "path", + "test/upper.test.js", + "plugins/upper.js:4" + ], + "want": [ + "call(s)" + ], + "avoid": [ + "never loads" + ] + } + ] +} diff --git a/tests/cases/javascript/a-test-that-never-loads-the-change/src/index.js b/tests/cases/javascript/a-test-that-never-loads-the-change/src/index.js new file mode 100644 index 00000000..7b150bf5 --- /dev/null +++ b/tests/cases/javascript/a-test-that-never-loads-the-change/src/index.js @@ -0,0 +1 @@ +module.exports = require('./lib/core.js') diff --git a/tests/cases/javascript/a-test-that-never-loads-the-change/src/lib/audit.js b/tests/cases/javascript/a-test-that-never-loads-the-change/src/lib/audit.js new file mode 100644 index 00000000..8d1cd9b5 --- /dev/null +++ b/tests/cases/javascript/a-test-that-never-loads-the-change/src/lib/audit.js @@ -0,0 +1,5 @@ +function audit(value) { + return 'audited ' + value +} + +module.exports = { audit } diff --git a/tests/cases/javascript/a-test-that-never-loads-the-change/src/lib/core.js b/tests/cases/javascript/a-test-that-never-loads-the-change/src/lib/core.js new file mode 100644 index 00000000..44aaed5a --- /dev/null +++ b/tests/cases/javascript/a-test-that-never-loads-the-change/src/lib/core.js @@ -0,0 +1,13 @@ +class Fmt { + constructor(v) { this.v = v } + render() { return '[' + this.parse(this.v) + ']' } + parse(v) { return String(v) } +} + +function extend(plugin) { plugin(Fmt) } + +function later(value, done) { + setTimeout(() => done(value), 0) +} + +module.exports = { Fmt, extend, later } diff --git a/tests/cases/javascript/a-test-that-never-loads-the-change/src/package.json b/tests/cases/javascript/a-test-that-never-loads-the-change/src/package.json new file mode 100644 index 00000000..9bdf58c6 --- /dev/null +++ b/tests/cases/javascript/a-test-that-never-loads-the-change/src/package.json @@ -0,0 +1 @@ +{"name": "fmtlib", "version": "1.0.0", "main": "index.js"} diff --git a/tests/cases/javascript/a-test-that-never-loads-the-change/src/plugins/upper.js b/tests/cases/javascript/a-test-that-never-loads-the-change/src/plugins/upper.js new file mode 100644 index 00000000..9242ca15 --- /dev/null +++ b/tests/cases/javascript/a-test-that-never-loads-the-change/src/plugins/upper.js @@ -0,0 +1,7 @@ +module.exports = function upper(C) { + const proto = C.prototype + const old = proto.parse + proto.parse = function (v) { + return old.call(this, v).toUpperCase() + } +} diff --git a/tests/cases/javascript/a-test-that-never-loads-the-change/src/test/audited.test.js b/tests/cases/javascript/a-test-that-never-loads-the-change/src/test/audited.test.js new file mode 100644 index 00000000..96e878b5 --- /dev/null +++ b/tests/cases/javascript/a-test-that-never-loads-the-change/src/test/audited.test.js @@ -0,0 +1,6 @@ +const { later } = require('fmtlib') +const { audit } = require('../lib/audit') + +test('audits what it is handed', (cb) => { + later(2, function check(v) { expect(audit(v)).toBe('audited 2'); cb() }) +}) diff --git a/tests/cases/javascript/a-test-that-never-loads-the-change/src/test/by-name.test.js b/tests/cases/javascript/a-test-that-never-loads-the-change/src/test/by-name.test.js new file mode 100644 index 00000000..52ad807e --- /dev/null +++ b/tests/cases/javascript/a-test-that-never-loads-the-change/src/test/by-name.test.js @@ -0,0 +1,7 @@ +const path = require('path') +const { Fmt, extend } = require('..') + +test('loads a plugin by its name', () => { + extend(require(path.join(__dirname, '..', 'plugins', process.env.PLUGIN || 'upper'))) + expect(new Fmt('b').render()).toBe('[B]') +}) diff --git a/tests/cases/javascript/a-test-that-never-loads-the-change/src/test/later.test.js b/tests/cases/javascript/a-test-that-never-loads-the-change/src/test/later.test.js new file mode 100644 index 00000000..dd8791d3 --- /dev/null +++ b/tests/cases/javascript/a-test-that-never-loads-the-change/src/test/later.test.js @@ -0,0 +1,5 @@ +const { later } = require('../') + +test('hands the value on', (cb) => { + later(1, function seen(v) { expect(v).toBe(1); cb() }) +}) diff --git a/tests/cases/javascript/a-test-that-never-loads-the-change/src/test/plain.test.js b/tests/cases/javascript/a-test-that-never-loads-the-change/src/test/plain.test.js new file mode 100644 index 00000000..4b566b09 --- /dev/null +++ b/tests/cases/javascript/a-test-that-never-loads-the-change/src/test/plain.test.js @@ -0,0 +1,5 @@ +const { Fmt } = require('..') + +test('renders a value', () => { + expect(new Fmt('a').render()).toBe('[a]') +}) diff --git a/tests/cases/javascript/a-test-that-never-loads-the-change/src/test/upper.test.js b/tests/cases/javascript/a-test-that-never-loads-the-change/src/test/upper.test.js new file mode 100644 index 00000000..32555671 --- /dev/null +++ b/tests/cases/javascript/a-test-that-never-loads-the-change/src/test/upper.test.js @@ -0,0 +1,8 @@ +const { Fmt, extend } = require('../index.js') +const upper = require('../plugins/upper') + +extend(upper) + +test('renders upper case', () => { + expect(new Fmt('a').render()).toBe('[A]') +}) diff --git a/tests/cases/javascript/mocha-load-time-fails-the-whole-run/case.json b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/case.json new file mode 100644 index 00000000..944a65ef --- /dev/null +++ b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/case.json @@ -0,0 +1,89 @@ +{ + "lang": "javascript", + "src": ".", + "checks": [ + { + "why": "a change a describe() block runs while mocha loads the specs fails the whole mocha run, so every spec file it collects is selected, not only the one that calls it", + "run": [ + "test-impact", + "{repo}", + "lib/build.js" + ], + "want": [ + "test/a.spec.js", + "test/b.spec.js", + "test/c.spec.js", + "test/d.spec.js", + "fails the whole run" + ] + }, + { + "why": "CONTROL: a change only a test body runs (inside it()) fails that spec alone", + "run": [ + "test-impact", + "{repo}", + "lib/parse.js" + ], + "want": [ + "test/c.spec.js" + ], + "avoid": [ + "test/a.spec.js", + "test/b.spec.js", + "fails the whole run" + ] + }, + { + "why": "CONTROL: jest loads each file in its own module registry, so the same describe()-time call fails that file alone", + "run": [ + "test-impact", + "{repo}", + "jestpkg/lib/build.js" + ], + "want": [ + "jestpkg/test/a.test.js" + ], + "avoid": [ + "jestpkg/test/b.test.js", + "fails the whole run" + ] + }, + { + "why": "a file under the tests that no runner collects is not a test run: one a collected spec requires is replaced by that spec, and a script nothing requires is not selected", + "run": [ + "test-impact", + "{repo}", + "lib/fmt.js" + ], + "want": [ + "test/b.spec.js", + "run only through the test files that require them", + "not selected: 1 file(s)", + "test/bench.js" + ], + "avoid": [ + "test/parts/extra.js (", + "test/bench.js (" + ] + }, + { + "why": "a function that is also a constructor (`function Thing(n)`) whose header line changes is a changed TYPE to `changed`; test-impact asks for it as the function it is and finds the spec that constructs it", + "run": [ + "test-impact", + "{repo}", + "--old", + "{repo}/edits/ctor-old.txt", + "--new", + "{repo}/edits/ctor-new.txt", + "--file", + "lib/ctor.js" + ], + "want": [ + "test/d.spec.js" + ], + "avoid": [ + "could not be resolved to a graph symbol" + ] + } + ] +} diff --git a/tests/cases/javascript/mocha-load-time-fails-the-whole-run/edits/ctor-new.txt b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/edits/ctor-new.txt new file mode 100644 index 00000000..878c2762 --- /dev/null +++ b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/edits/ctor-new.txt @@ -0,0 +1,9 @@ +function Thing(n, unit) { + this.n = n +} + +Thing.prototype.twice = function twice() { + return this.n * 2 +} + +module.exports = Thing diff --git a/tests/cases/javascript/mocha-load-time-fails-the-whole-run/edits/ctor-old.txt b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/edits/ctor-old.txt new file mode 100644 index 00000000..c4bd522f --- /dev/null +++ b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/edits/ctor-old.txt @@ -0,0 +1,9 @@ +function Thing(n) { + this.n = n +} + +Thing.prototype.twice = function twice() { + return this.n * 2 +} + +module.exports = Thing diff --git a/tests/cases/javascript/mocha-load-time-fails-the-whole-run/jestpkg/lib/build.js b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/jestpkg/lib/build.js new file mode 100644 index 00000000..3e4e5e0f --- /dev/null +++ b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/jestpkg/lib/build.js @@ -0,0 +1,5 @@ +function jbuild(o) { + return Object.keys(o) +} + +module.exports = { jbuild } diff --git a/tests/cases/javascript/mocha-load-time-fails-the-whole-run/jestpkg/package.json b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/jestpkg/package.json new file mode 100644 index 00000000..e6e5ed84 --- /dev/null +++ b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/jestpkg/package.json @@ -0,0 +1 @@ +{"name": "jestpkg", "version": "1.0.0", "scripts": {"test": "jest"}, "devDependencies": {"jest": "^29.0.0"}} diff --git a/tests/cases/javascript/mocha-load-time-fails-the-whole-run/jestpkg/test/a.test.js b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/jestpkg/test/a.test.js new file mode 100644 index 00000000..d18814b4 --- /dev/null +++ b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/jestpkg/test/a.test.js @@ -0,0 +1,8 @@ +const { jbuild } = require('../lib/build') + +describe('jbuild', () => { + const keys = jbuild({ y: 1 }) + test('lists the keys', () => { + expect(keys).toEqual(['y']) + }) +}) diff --git a/tests/cases/javascript/mocha-load-time-fails-the-whole-run/jestpkg/test/b.test.js b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/jestpkg/test/b.test.js new file mode 100644 index 00000000..17bdeac5 --- /dev/null +++ b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/jestpkg/test/b.test.js @@ -0,0 +1,3 @@ +test('adds', () => { + expect(1 + 1).toBe(2) +}) diff --git a/tests/cases/javascript/mocha-load-time-fails-the-whole-run/lib/build.js b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/lib/build.js new file mode 100644 index 00000000..f5598d58 --- /dev/null +++ b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/lib/build.js @@ -0,0 +1,5 @@ +function build(o) { + return Object.keys(o) +} + +module.exports = { build } diff --git a/tests/cases/javascript/mocha-load-time-fails-the-whole-run/lib/ctor.js b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/lib/ctor.js new file mode 100644 index 00000000..c4bd522f --- /dev/null +++ b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/lib/ctor.js @@ -0,0 +1,9 @@ +function Thing(n) { + this.n = n +} + +Thing.prototype.twice = function twice() { + return this.n * 2 +} + +module.exports = Thing diff --git a/tests/cases/javascript/mocha-load-time-fails-the-whole-run/lib/fmt.js b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/lib/fmt.js new file mode 100644 index 00000000..d4d9d97e --- /dev/null +++ b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/lib/fmt.js @@ -0,0 +1,5 @@ +function fmt(s) { + return '<' + s + '>' +} + +module.exports = { fmt } diff --git a/tests/cases/javascript/mocha-load-time-fails-the-whole-run/lib/parse.js b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/lib/parse.js new file mode 100644 index 00000000..8499cfe3 --- /dev/null +++ b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/lib/parse.js @@ -0,0 +1,5 @@ +function parse(s) { + return s.trim() +} + +module.exports = { parse } diff --git a/tests/cases/javascript/mocha-load-time-fails-the-whole-run/package.json b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/package.json new file mode 100644 index 00000000..0826e6db --- /dev/null +++ b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/package.json @@ -0,0 +1,2 @@ +{"name": "loadtime", "version": "1.0.0", "scripts": {"test": "mocha"}, "devDependencies": {"mocha": "^10.0.0"}, + "mocha": {"spec": "test/*.spec.js"}} diff --git a/tests/cases/javascript/mocha-load-time-fails-the-whole-run/test/a.spec.js b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/test/a.spec.js new file mode 100644 index 00000000..12865cef --- /dev/null +++ b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/test/a.spec.js @@ -0,0 +1,9 @@ +const assert = require('assert') +const { build } = require('../lib/build') + +describe('build', function () { + const keys = build({ x: 1 }) + it('lists the keys', function () { + assert.deepStrictEqual(keys, ['x']) + }) +}) diff --git a/tests/cases/javascript/mocha-load-time-fails-the-whole-run/test/b.spec.js b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/test/b.spec.js new file mode 100644 index 00000000..c16be893 --- /dev/null +++ b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/test/b.spec.js @@ -0,0 +1,9 @@ +const assert = require('assert') +const { fmt } = require('../lib/fmt') + +describe('fmt', function () { + it('wraps', function () { + assert.strictEqual(fmt('x'), '') + }) + require('./parts/extra') +}) diff --git a/tests/cases/javascript/mocha-load-time-fails-the-whole-run/test/bench.js b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/test/bench.js new file mode 100644 index 00000000..570352a9 --- /dev/null +++ b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/test/bench.js @@ -0,0 +1,5 @@ +const { fmt } = require('../lib/fmt') + +const t = Date.now() +for (let i = 0; i < 1e5; i++) fmt('b') +console.log(Date.now() - t) diff --git a/tests/cases/javascript/mocha-load-time-fails-the-whole-run/test/c.spec.js b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/test/c.spec.js new file mode 100644 index 00000000..e96d3693 --- /dev/null +++ b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/test/c.spec.js @@ -0,0 +1,8 @@ +const assert = require('assert') +const { parse } = require('../lib/parse') + +describe('parse', function () { + it('trims', function () { + assert.strictEqual(parse(' x '), 'x') + }) +}) diff --git a/tests/cases/javascript/mocha-load-time-fails-the-whole-run/test/d.spec.js b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/test/d.spec.js new file mode 100644 index 00000000..e10d1828 --- /dev/null +++ b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/test/d.spec.js @@ -0,0 +1,8 @@ +const assert = require('assert') +const Thing = require('../lib/ctor') + +describe('Thing', function () { + it('builds one', function () { + assert.strictEqual(new Thing(2).n, 2) + }) +}) diff --git a/tests/cases/javascript/mocha-load-time-fails-the-whole-run/test/parts/extra.js b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/test/parts/extra.js new file mode 100644 index 00000000..456484a0 --- /dev/null +++ b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/test/parts/extra.js @@ -0,0 +1,6 @@ +const assert = require('assert') +const { fmt } = require('../../lib/fmt') + +it('wraps the empty string', function () { + assert.strictEqual(fmt(''), '<>') +}) From ea07409dd5850e1b38d1ec69dcb056be3bd9841e Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:33:11 -0700 Subject: [PATCH 09/11] feat(links): columns, rejections, candidates, derived edges on a link's result (#1892) A site is file:line:col (the callee's name); two calls of one name on a line are never chosen between. link --not rejects a by-name or one-of-a-set lead at a site (never an edge the engine resolved); the walks skip it, nothing is deleted. AXIOMCODE_LINKS_PREFER=1 skips a linked site's own guesses. Unknown sites carry ranked candidates from what the graph knows (the engine's set, values passed in, a computed name's prefix, values registered in the file, the name called). A link whose target has a declared, JSDoc or inferred return type resolves the calls chained on its result, on a local assigned from it, awaited, or on each element it yields. Front-door answers list confirmed places before leads. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../skills/axiomcode/scripts/ax_blocks.py | 14 +- .../skills/axiomcode/scripts/ax_links.py | 268 ++++++++++++++++-- .../skills/axiomcode/scripts/axiomcode-impact | 13 +- .../skills/axiomcode/scripts/axiomcode-path | 12 +- .../skills/axiomcode/scripts/dl/impact.dl | 6 +- .../skills/axiomcode/scripts/dl/path-opt.dl | 3 +- .../skills/axiomcode/scripts/graph_sql.py | 4 + .../csharp/asserted-links-derive/App/Route.cs | 44 +++ .../csharp/asserted-links-derive/App/Views.cs | 27 ++ .../csharp/asserted-links-derive/case.json | 26 ++ .../java/asserted-links-derive/case.json | 11 + .../src/pkg/Builder.java | 6 + .../src/pkg/Response.java | 6 + .../asserted-links-derive/src/pkg/Route.java | 29 ++ .../asserted-links-derive/src/pkg/Views.java | 12 + .../asserted-links-derive/case.json | 24 ++ .../asserted-links-derive/src/route.js | 36 +++ .../asserted-links-derive/src/views.js | 20 ++ .../python/asserted-links/app/dispatch.py | 4 + tests/cases/python/asserted-links/case.json | 131 +++++++++ .../asserted-links-derive/case.json | 27 ++ .../asserted-links-derive/src/route.ts | 43 +++ .../asserted-links-derive/src/views.ts | 16 ++ tests/run.py | 5 +- 24 files changed, 754 insertions(+), 33 deletions(-) create mode 100644 tests/cases/csharp/asserted-links-derive/App/Route.cs create mode 100644 tests/cases/csharp/asserted-links-derive/App/Views.cs create mode 100644 tests/cases/csharp/asserted-links-derive/case.json create mode 100644 tests/cases/java/asserted-links-derive/case.json create mode 100644 tests/cases/java/asserted-links-derive/src/pkg/Builder.java create mode 100644 tests/cases/java/asserted-links-derive/src/pkg/Response.java create mode 100644 tests/cases/java/asserted-links-derive/src/pkg/Route.java create mode 100644 tests/cases/java/asserted-links-derive/src/pkg/Views.java create mode 100644 tests/cases/javascript/asserted-links-derive/case.json create mode 100644 tests/cases/javascript/asserted-links-derive/src/route.js create mode 100644 tests/cases/javascript/asserted-links-derive/src/views.js create mode 100644 tests/cases/typescript/asserted-links-derive/case.json create mode 100644 tests/cases/typescript/asserted-links-derive/src/route.ts create mode 100644 tests/cases/typescript/asserted-links-derive/src/views.ts diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_blocks.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_blocks.py index 0572a4e4..86ce35f8 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_blocks.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_blocks.py @@ -19,6 +19,7 @@ import ax_grep CAP = 10 # places shown; the rest are counted +LEAD_CERTS = {'by name', 'by key', 'decorator by name', 'one of a set', 'text', 'in scope', 'capped set', 'protocol', 'library callback'} FAR = 8 # places more than one hop away, named without code DIRECT_CODE = 3 # direct callers shown with code even when a word grep also finds them PLAIN_WHY = ('calls it', 'reads it', 'writes it', 'writes/reads it', 'references it', 'instantiates it') @@ -140,7 +141,18 @@ def greppable(p): far = [p for p in places.values() if is_far(p) and not is_test(p)] places = {k: p for k, p in places.items() if not is_far(p) and not is_test(p)} out = [] - for i, p in enumerate(list(places.values())[:CAP], 1): + # CONFIRMED FIRST, THEN LEADS: a place backed by an edge (resolved, or asserted by a link) before one reached only + # through a guess (by name, by key, one of a set, text). One place per function, so a function reached both ways is + # listed once, as confirmed; the guesses get a heading of their own only when both kinds are present. + def is_lead(p): + certs = [t.split(' · ')[0].strip() for t in p['tags'] if ' · ' in t] + return bool(certs) and all(c in LEAD_CERTS for c in certs) + ordered = [p for p in places.values() if not is_lead(p)] + [p for p in places.values() if is_lead(p)] + places = {id(p): p for p in ordered} + any_confirmed = any(not is_lead(p) for p in ordered) + for i, p in enumerate(ordered[:CAP], 1): + if is_lead(p) and any_confirmed and (i == 1 or not is_lead(ordered[i - 2])): + out.append("leads — reached only through a guess; check each before relying on it:") where = f"{p['f']}:{','.join(map(str, sorted(p['marks'])))}" out.append(f"{i}. {where}" + (f" [{' | '.join(p['tags'][:2])}]" if p['tags'] else '')) body = block(repo, p['f'], p['marks'], p['span']) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_links.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_links.py index 93dd4dc9..0efa6f4d 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_links.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_links.py @@ -31,7 +31,7 @@ import hashlib, json, os, re, sqlite3, subprocess, sys, time FILE_NAME = 'axiomcode-links.tsv' -COLS = ('file', 'line', 'line_sha', 'callee', 'caller', 'target', 'target_file', 'by', 'at', 'col', 'ncol') +COLS = ('file', 'line', 'line_sha', 'callee', 'caller', 'target', 'target_file', 'by', 'at', 'col', 'ncol', 'not') HEADER = ('# axiomcode links: call edges asserted where the graph could not resolve the call. ' 'Written by `axiomcode link`; one per line, tab-separated: ' + ' '.join(COLS)) TIER = 'asserted' @@ -111,6 +111,7 @@ def read_links(repo): d['line'] = int(d['line']); d['n'] = n d['col'] = int(d['col']) if str(d.get('col') or '').isdigit() else None d['ncol'] = int(d['ncol']) if str(d.get('ncol') or '').isdigit() else None + d['not'] = str(d.get('not') or '').strip().lower() in ('1', 'not', 'yes', 'true') out.append(d) return out, bad @@ -125,7 +126,9 @@ def write_links(repo, links): with open(tmp, 'w', encoding='utf-8') as fh: fh.write(HEADER + '\n') for l in keep: fh.write(l + '\n') - for d in links: fh.write('\t'.join(('' if d.get(c) is None else str(d.get(c))).replace('\t', ' ').replace('\n', ' ') for c in COLS).rstrip('\t') + '\n') + for d in links: + d = dict(d, **{'not': 'not' if d.get('not') else ''}) + fh.write('\t'.join(('' if d.get(c) is None else str(d.get(c))).replace('\t', ' ').replace('\n', ' ') for c in COLS).rstrip('\t') + '\n') os.replace(tmp, p) @@ -442,6 +445,7 @@ def clean_type(t, owner=''): parts = [x.strip() for x in re.split(r'\|', t) if x.strip() not in ('None', 'null', 'undefined', 'void')] if '|' in t and '<' not in t and '[' not in t else [t] if len(parts) != 1: return '' t = parts[0] + if t.endswith('[]'): return '' # an array: its element is element_type's head, args = split_generic(t) last = head.split('.')[-1] if last in WRAPPERS and args: return clean_type(args[0], owner) @@ -471,13 +475,8 @@ def owner_type(self, mid): return (r[0][0], d[0][0] if d else r[0][0]) return None def declared(self, mid): - """the return type as WRITTEN: (text, how) from the graph's type_use, else the declaration's own header""" + """the return type as WRITTEN: (text, how) from the declaration's own header (generics and arrays as written), else the graph's type_use""" g = self.g - if 'type_use' in g.tables: - rows = g.q("SELECT tu.depth, COALESCE(sy.display, ty.name) FROM type_use tu LEFT JOIN symbols sy ON sy.type_id = tu.type_id AND sy.method_id IS NULL " - "LEFT JOIN types ty ON ty.id = tu.type_id WHERE tu.owner_method_id = ? AND tu.context = 'METHOD_RETURN' ORDER BY tu.depth", mid) if 'types' in g.tables else [] - names = [n for _d, n in rows if n] - if names: return (names[0] + (f"<{', '.join(names[1:])}>" if len(names) > 1 else ''), 'declared') sy = g.q("SELECT name, file, line, end_line FROM symbols WHERE method_id = ? AND file IS NOT NULL LIMIT 1", mid) if not sy: return None name, f, ln, end = sy[0] @@ -499,10 +498,20 @@ def declared(self, mid): m = re.search(r'([\w.$]+(?:\s*<[^()]*?>)?(?:\[\])?\??)\s+' + re.escape(name) + r'\s*(?:<[^>]*>)?\s*\(', head) if m and m.group(1) not in ('new', 'return', 'void', 'else'): return (m.group(1), 'declared') if lang in ('javascript', 'typescript'): - for k in range(max(0, ln - 16), ln - 1): + # the doc comment directly above the declaration, and only that one + k = ln - 2 + while k >= 0 and k >= ln - 40 and re.match(r'\s*(/\*\*|\*|\*/|//|@)', L[k]): m = re.search(r'@returns?\s*\{([^}]+)\}', L[k]) if m: return (m.group(1), 'JSDoc') + k -= 1 + # the graph's own type_use when the header says nothing the patterns read + if 'type_use' in g.tables: + rows = g.q("SELECT tu.depth, COALESCE(sy.display, ty.name) FROM type_use tu LEFT JOIN symbols sy ON sy.type_id = tu.type_id AND sy.method_id IS NULL " + "LEFT JOIN types ty ON ty.id = tu.type_id WHERE tu.owner_method_id = ? AND tu.context = 'METHOD_RETURN' ORDER BY tu.depth", mid) if 'types' in g.tables else [] + names = [n for _d, n in rows if n] + if names: return (names[0] + (f"<{', '.join(names[1:])}>" if len(names) > 1 else ''), 'declared') return None + def inferred(self, mid): """the type every `return` of the body names: `self` / `this` (the owner), `new T(…)` / `T(…)` of a type in the graph""" sy = self.g.q("SELECT file, line, end_line FROM symbols WHERE method_id = ? AND file IS NOT NULL LIMIT 1", mid) @@ -510,8 +519,8 @@ def inferred(self, mid): f, ln, end = sy[0] L = self.reader.lines(f) or [] got = set() - for t in L[ln: (end or ln)]: - m = re.search(r'\breturn\s+(?:await\s+)?(.+?)\s*;?\s*$', t) + for t in L[ln - 1: (end or ln)]: + m = re.search(r'\breturn\s+(?:await\s+)?(.+?)\s*;?\s*}?\s*$', t) if not m: continue v = m.group(1) if re.fullmatch(r'(self|this)', v): o = self.owner_type(mid); got.add(o[1].split('.')[-1] if o else '?'); continue @@ -629,6 +638,100 @@ def chain(ln, pos, t, how): return out, (f"derived {n} edge(s) on its result ({rt[1]}, {rt[2]})" if n else f"returns {rt[1]} ({rt[2]}); no call on its result here") + +# ── REJECTIONS: a lead at a site that someone read and found wrong ────────────────────────────────────────────── +# `axiomcode link --not `. Only a GUESS can be rejected: a by-name match at an unresolved site, or one +# member of a target set the engine could not narrow (one of a set, a capped fan). Nothing is deleted: the pair is +# skipped where the walks read their facts (impact's calls / rejected, path's edge / rejected_edge), so removing the +# rejection restores it at once. An edge the engine resolved is never hidden this way. +LEAD_TIERS = ('multi_inferred', 'fan_capped') +REJ_TABLE = ("CREATE TABLE IF NOT EXISTS asserted_rejections(n INT, call_site_id TEXT, caller_id TEXT, callee_id TEXT, file TEXT, line INT, " + "status TEXT, reason TEXT)") + + +def resolve_not(g, reader, link, asserted_pairs): + """-> dict(status, reason, site, caller, callee_id, line) for a rejection""" + r = dict(status='rejected', reason='', site=None, caller=None, callee_id=None, line=None) + ts = g.targets(link['target'], link.get('target_file') or '') + if not ts: + r.update(status='stale' if link.get('target_file') else 'rejected', + reason='the target is not a declaration in this graph (renamed, deleted or moved?)'); return r + if len(ts) > 1: r['reason'] = f"the target names {len(ts)} declarations: name it as file:line"; return r + mid, disp, tf, prov, tname, tkind, towner = ts[0] + r['callee_id'] = mid; r['target_file'] = tf; r['target_display'] = disp + line, sites, why = locate(g, reader, link) + if why: r.update(status='stale', reason=why); return r + r['line'] = line + if link.get('col') or link.get('ncol') is not None: + L = reader.lines(link['file']) or [] + text = L[line - 1] if 0 < line <= len(L) else '' + col = denorm_col(text, link['ncol']) if link.get('ncol') is not None else link['col'] + sites = [x for x in sites if x['col'] == col] + if not sites: r.update(status='stale', reason=f"stale: no call's name starts at column {col}"); return r + if not sites: r['reason'] = f"no call is written at {link['file']}:{line}"; return r + lead, refuse = [], '' + for x in sites: + tiers = {t for (t,) in g.q("SELECT tier FROM call_edges WHERE call_site_id = ? AND callee_method_id = ?", x['id'], mid)} + if (x['id'], mid) in asserted_pairs or tiers == {TIER}: + refuse = refuse or "that edge is an asserted link, not a guess: remove the link instead (`axiomcode link -`)" + elif tiers & set(LEAD_TIERS) and not (tiers - set(LEAD_TIERS) - {TIER}): + lead.append(x) + elif tiers: + refuse = refuse or "the engine resolved this call; if it is wrong that is an engine defect — not hidden" + elif g.unresolved(x['id']) and (x['callee'] or '').split('.')[-1] == tname: + lead.append(x) + if not lead: + r['reason'] = refuse or f"no by-name or one-of-a-set lead at {link['file']}:{line} reaches {disp}"; return r + if len(lead) > 1: + r['reason'] = f"{len(lead)} calls on that line lead to {disp}: give the column"; return r + x = lead[0] + r.update(status='applied' if line == link['line'] else 'moved', site=x['id'], caller=x['caller'], col=x['col'], callee_written=x['callee']) + return r + + +def prefer_on(): + """AXIOMCODE_LINKS_PREFER=1: at a site an asserted link settles, the site's own guesses are not walked either""" + return os.environ.get('AXIOMCODE_LINKS_PREFER', '').lower() in ('1', 'on', 'true', 'yes') + + +def suppressed(q, site_file=lambda f: f): + """-> (pairs {(caller, callee, file, line)}, edges {(caller, callee)}): the leads the walks skip. A pair is a site and + a target; an edge (path's by-name / set edges carry no site) is skipped only when EVERY site of that caller leading + to that callee is suppressed.""" + tabs = {r[0] for r in q("SELECT name FROM sqlite_master WHERE type IN ('table','view')")} + sites = set() # (site id, callee) + if 'asserted_rejections' in tabs: + sites |= {(a, b) for a, b in q("SELECT call_site_id, callee_id FROM asserted_rejections WHERE status IN ('applied','moved')")} + if prefer_on(): + linked = {a for (a,) in q("SELECT DISTINCT call_site_id FROM call_edges WHERE tier = 'asserted'")} + for sid in linked: + keep = {b for (b,) in q("SELECT callee_method_id FROM call_edges WHERE call_site_id = ? AND tier = 'asserted'", sid)} + sites |= {(sid, b) for (b,) in q(f"SELECT callee_method_id FROM call_edges WHERE call_site_id = ? AND tier IN ('multi_inferred','fan_capped')", sid) if b not in keep} + if 'unresolved_sites' in tabs and q("SELECT 1 FROM unresolved_sites WHERE call_site_id = ?", sid): + n = q("SELECT callee_name FROM call_sites WHERE id = ?", sid) + nm = (n[0][0] or '').split('.')[-1] if n else '' + if nm: sites |= {(sid, b) for (b,) in q("SELECT method_id FROM symbols WHERE name = ? AND method_id IS NOT NULL", nm) if b not in keep} + if not sites: return set(), set() + info = {} + ids = sorted({a for a, _ in sites}) + for i in range(0, len(ids), 500): + ch = ids[i:i + 500] + for sid, c, n, f, l in q(f"SELECT id, caller_id, callee_name, file_path, start_line FROM call_sites WHERE id IN ({','.join('?' * len(ch))})", *ch): + info[sid] = (c, (n or '').split('.')[-1], site_file(f) if f else '', l or 0) + pairs = {(info[a][0], b, info[a][2], info[a][3]) for a, b in sites if a in info} + # an edge is gone only when all of its sites are: the caller's other sites of that name / set still lead there + edges = set() + by_edge = {} + for a, b in sites: + if a in info: by_edge.setdefault((info[a][0], b), set()).add(a) + for (c, b), ss in by_edge.items(): + multi = {x for (x,) in q("SELECT call_site_id FROM call_edges WHERE caller_id = ? AND callee_method_id = ? AND tier IN ('multi_inferred','fan_capped')", c, b)} + nm = {info[x][1] for x in ss} + byname = {x for n_ in nm if n_ for (x,) in (q("SELECT s.id FROM call_sites s JOIN unresolved_sites u ON u.call_site_id = s.id WHERE s.caller_id = ? AND (s.callee_name = ? OR s.callee_name LIKE ?)", c, n_, '%.' + n_) if 'unresolved_sites' in tabs else [])} + if (multi | byname) <= ss: edges.add((c, b)) + return pairs, edges + + DERIVED_TABLE = "CREATE TABLE IF NOT EXISTS asserted_derived(n INT, call_site_id TEXT, callee_id TEXT, label TEXT, how TEXT)" LINK_TABLE = ("CREATE TABLE IF NOT EXISTS asserted_links(n INT, file TEXT, line INT, at_line INT, target TEXT, target_id TEXT, " "call_site_id TEXT, caller_id TEXT, status TEXT, reason TEXT)") @@ -643,8 +746,10 @@ def apply_db(db, repo, reader, links=None, bad=None, sha=None): try: g = Graph(con) if 'call_sites' not in g.tables or 'call_edges' not in g.tables: return [] - had = bool(con.execute("SELECT 1 FROM call_edges WHERE tier = ? LIMIT 1", (TIER,)).fetchone()) or 'asserted_links' in g.tables + had = bool(con.execute("SELECT 1 FROM call_edges WHERE tier = ? LIMIT 1", (TIER,)).fetchone()) or 'asserted_links' in g.tables or 'asserted_rejections' in g.tables if not links and not bad and not had: return [] + rejs = [l for l in links if l.get('not')] + links = [l for l in links if not l.get('not')] res = [(l, resolve(g, reader, l)) for l in links] # what each applied link makes resolvable after it, read before any row is written (the engine's own edges decide) derived = {} @@ -671,6 +776,14 @@ def apply_db(db, repo, reader, links=None, bad=None, sha=None): r['reason'] = ('; '.join(x for x in (r['reason'], why) if x)) con.execute("INSERT INTO asserted_links VALUES (?,?,?,?,?,?,?,?,?,?)", (l['n'], l['file'], l['line'], r['line'], l['target'], r['callee_id'], r['site'], r['caller'], r['status'], r['reason'])) + con.execute(REJ_TABLE); con.execute("DELETE FROM asserted_rejections") + apairs = {(r['site'], r['callee_id']) for _l, r in res if r['status'] in ('applied', 'moved')} + for l in rejs: + r = resolve_not(g, reader, l, apairs) + res.append((l, r)) + con.execute("INSERT INTO asserted_rejections VALUES (?,?,?,?,?,?,?,?)", (l['n'], r['site'], r['caller'], r['callee_id'], l['file'], r['line'], r['status'], r['reason'])) + con.execute("INSERT INTO asserted_links VALUES (?,?,?,?,?,?,?,?,?,?)", + (l['n'], l['file'], l['line'], r['line'], 'not ' + l['target'], r['callee_id'], r['site'], r['caller'], r['status'], r['reason'])) for n, raw, why in bad or []: con.execute("INSERT INTO asserted_links VALUES (?,?,?,?,?,?,?,?,?,?)", (n, '', None, None, raw[:120], None, None, None, 'malformed', why)) if 'index_meta' in g.tables: @@ -733,17 +846,27 @@ def asserted_rows(db): con.close() +def _has_rejections(db): + try: + con = sqlite3.connect(f"file:{db}?mode=ro", uri=True) + try: return bool(con.execute("SELECT 1 FROM asserted_rejections LIMIT 1").fetchone()) + finally: con.close() + except sqlite3.Error: return False + + def apply_repo(repo, quiet=True): """apply the links file to every graph of the repository, patching each one's facts. -> {graph: results}""" links, bad = read_links(repo); sha = file_sha(repo); out = {} for db, reader, live in graph_dbs(repo): try: old = os.stat(db).st_mtime except OSError: continue + had_rej = _has_rejections(db) try: res = apply_db(db, repo, reader, links, bad, sha) except sqlite3.Error as e: if not quiet: print(f"axiomcode link: {db}: {e}", file=sys.stderr) continue - if res or links or bad: patch_facts(db, old, asserted_rows(db)) + # a rejection (or the prefer mode) changes the by-name and set facts too: those graphs re-export on the next query + if (res or links or bad) and not prefer_on() and not had_rej and not any(l.get('not') for l in links): patch_facts(db, old, asserted_rows(db)) out[db] = (live, res) return out @@ -774,6 +897,7 @@ def note(q): """one line for an answer when some link was not applied, else ''""" rows = status_rows(q) off = [r for r in rows if r[5] in ('stale', 'rejected', 'malformed')] + nrej = sum(1 for r in rows if str(r[4]).startswith('not ') and r[5] in ('applied', 'moved')) if not off: return '' by = {} for r in off: by[r[5]] = by.get(r[5], 0) + 1 @@ -781,6 +905,75 @@ def note(q): f" — `axiomcode link` lists them") +def rejected_note(q): + rows = status_rows(q) + n = sum(1 for r in rows if str(r[4]).startswith('not ') and r[5] in ('applied', 'moved')) + return f"links: {n} lead(s) rejected by a link are not walked — `axiomcode link` lists them" if n else '' + + + +# ── CANDIDATES: what the graph already knows about where an unknown site may land ─────────────────────────────── +# Never from a runtime trace: the engine's own target set at the site, the callables handed into the called value by +# the caller's callers, a computed name's constant prefix, callables registered as values in the same file, and +# declarations of the callee's own name. Ranked in that order, at most `cap`. A candidate is a LEAD: the agent confirms +# it by reading the call, never by its rank. +def candidates(q, sid, caller, callee, kind, file_, line, reason, reader, cap=5): + out, seen = [], set() + def add(mid, why): + if not mid or mid in seen or len(out) >= cap: return + r = q("SELECT display, file, line FROM symbols WHERE method_id = ? AND kind <> 'module' LIMIT 1", mid) + if not r: return + seen.add(mid); out.append(dict(target=r[0][0], at=f"{r[0][1]}:{r[0][2]}", why=why)) + for (m,) in q("SELECT callee_method_id FROM call_edges WHERE call_site_id = ? AND tier IN ('multi_inferred','fan_capped') AND callee_provenance = 'client'", sid): + add(m, "the engine's own candidate set") + nm = (callee or '').split('.')[-1] + L = reader.lines(file_) if reader and file_ else None + cs = q("SELECT name, file, line, end_line, signature FROM symbols WHERE id = ? LIMIT 1", caller) + # a parameter called: what the callers pass in that position + if nm and cs and L is not None: + cname, cf, cl, ce, sig = cs[0] + params = [re.split(r'[:=\s]', p.strip().lstrip('*&'))[0] for p in re.sub(r'^[^(]*\(|\)[^)]*$', '', sig or '').split(',') if p.strip()] + if nm in params: + k = params.index(nm) + for s_f, s_l, s_sc in q("SELECT s.file_path, s.start_line, s.start_column FROM call_edges e JOIN call_sites s ON s.id = e.call_site_id " + "WHERE e.callee_method_id = (SELECT method_id FROM symbols WHERE id = ?) AND e.tier <> 'asserted' LIMIT 20", caller): + rel = q("SELECT rel FROM paths WHERE raw = ?", s_f); rel = rel[0][0] if rel else s_f + LL = reader.lines(rel) or [] + t = LL[s_l - 1] if 0 < (s_l or 0) <= len(LL) else '' + mm = re.search(re.escape(cname) + r'\s*\((.*)', t) + if not mm: continue + args = [a.strip() for a in re.split(r',(?![^()\[\]{}]*[)\]}])', mm.group(1).rsplit(')', 1)[0])] + a = args[k - (1 if params and params[0] in ('self', 'cls', 'this') else 0)] if k < len(args) + 1 else '' + a = (a or '').split('=')[-1].strip().split('.')[-1] + for (m,) in q("SELECT method_id FROM symbols WHERE name = ? AND method_id IS NOT NULL AND kind <> 'module'", a): + add(m, f"passed in by a caller at {rel}:{s_l}") + # a name computed from a constant prefix (the engine's reason carries it: computed_attribute_name:on_*) + pref = '' + mr = re.search(r'computed_attribute_name:(\w*)\*', reason or '') + if mr: pref = mr.group(1) + elif L is not None and cs: + body = '\n'.join(L[cs[0][2] - 1: cs[0][3] or cs[0][2]]) + mp = re.search(r'["\'`](\w{2,})["\'`]\s*\+|`(\w{2,})\$\{|f["\'](\w{2,})\{', body) + if mp: pref = next(g_ for g_ in mp.groups() if g_) + if pref: + for (m,) in q("SELECT method_id FROM symbols WHERE name LIKE ? ESCAPE '\\' AND method_id IS NOT NULL AND kind <> 'module' ORDER BY file = ? DESC, line LIMIT 20", + pref.replace('\\', '').replace('%', '').replace('_', '\\_') + '%', file_): + add(m, f"named {pref}…, the constant part of the computed name") + # callables registered as VALUES in the same file (a table, a list, a register(...) call) + if L is not None: + vals = set() + for t in L: + for v in re.findall(r'[:\[,(=]\s*([A-Za-z_$][\w$]*)\s*(?=[,\]})])', t): vals.add(v) + for v in sorted(vals): + for (m,) in q("SELECT method_id FROM symbols WHERE name = ? AND method_id IS NOT NULL AND kind NOT IN ('module', 'class') LIMIT 3", v): + add(m, "handed over as a value in this file") + # declarations of the callee's own name (a member call on an untyped receiver) + if nm: + for (m,) in q("SELECT method_id FROM symbols WHERE name = ? AND method_id IS NOT NULL AND kind <> 'module' LIMIT 10", nm): + add(m, "a declaration of the name called") + return out + + def unknown_sites(q, callers, site_file, limit=30, order=None, repo=None): """the unresolved call sites inside these callables: where the answer stops being complete. Each with the call as written, the engine's own reason, and the targets it was linked to (if any).""" @@ -804,6 +997,13 @@ def unknown_sites(q, callers, site_file, limit=30, order=None, repo=None): f"JOIN call_sites s ON s.id = e.call_site_id JOIN methods m ON m.id = e.callee_method_id " f"WHERE m.kind IN ('FUNCTION_TYPE_SIGNATURE', 'CALL_SIGNATURE', 'TYPE_LITERAL_CALL_SIGNATURE') AND e.caller_id IN ({ph})", *ch): rows.append((sid, c, k, n, f, l)); libcall[sid] = 'a function-type signature, not a body' + # …and a site whose every engine candidate a link rejected: never silently empty, it is to resolve again + if 'asserted_rejections' in tabs: + for sid, c, k, n, f, l in q(f"SELECT DISTINCT s.id, s.caller_id, s.kind, s.callee_name, s.file_path, s.start_line FROM asserted_rejections r " + f"JOIN call_sites s ON s.id = r.call_site_id WHERE r.status IN ('applied','moved') AND s.caller_id IN ({ph})", *ch): + left = q("SELECT 1 FROM call_edges e WHERE e.call_site_id = ? AND e.tier IN ('multi_inferred','fan_capped') AND NOT EXISTS " + "(SELECT 1 FROM asserted_rejections r WHERE r.call_site_id = e.call_site_id AND r.callee_id = e.callee_method_id AND r.status IN ('applied','moved')) LIMIT 1", sid) + if not left: rows.append((sid, c, k, n, f, l)) rows = list(dict.fromkeys(rows)) total = len(rows) rank = {c: i for i, c in enumerate(order or callers)} @@ -838,7 +1038,11 @@ def col_of(sid, f, callee): lc = str(libcall.get(sid, '')) why = reasons.get(sid) or ((f"calls a value typed by {lc}" if ' ' in lc else f"runs a value through {lc.split(':')[-1]}") if sid in libcall else 'unresolved') rf = site_file(f) if f else '?' - out.append(dict(at=f"{rf}:{ln or 0}", site=f"{rf}:{ln or 0}:{col_of(sid, rf, callee)}", call=callee or '', kind=kind, caller=disp[caller], + try: cands = candidates(q, sid, caller, callee, kind, rf, ln, reasons.get(sid), rd) + except Exception: cands = [] + site = f"{rf}:{ln or 0}:{col_of(sid, rf, callee)}" + out.append(dict(at=f"{rf}:{ln or 0}", site=site, call=callee or '', kind=kind, caller=disp[caller], candidates=cands, + command=f"axiomcode link {site} {cands[0]['target'] if cands else ''}", reason=why, linked=sorted(linked.get(sid, [])))) return out, total @@ -854,13 +1058,22 @@ def code_at(repo, at): def unknown_lines(repo, sites, total, shown=None): """the `unknown:` block of an answer""" if not sites: return [] + # a site a link settled is no longer to resolve: counted, not listed + nlinked = sum(1 for x in sites if x['linked']) + sites = [x for x in sites if not x['linked']] + total = max(0, total - nlinked) + if not sites: return [f"to resolve: nothing — {nlinked} site(s) the answer stopped at are settled by links"] if nlinked else [] shown = sites[:shown] if shown else sites - out = [f"unknown: {total} unresolved call site(s) inside — what each reaches is not in this answer" + out = [f"to resolve: {total} call site(s) the answer stopped at — what each reaches is not in it" + (f" (first {len(shown)})" if total > len(shown) else '') + ':'] for s in shown: code = code_at(repo, s['at']) out.append(f" {s.get('site') or s['at']} {code[:90]} [{s['reason']}]" + (f" → linked: {', '.join(s['linked'])} [asserted]" if s['linked'] else '')) - out.append(" if the code shows where one of these lands and the task depends on it: `axiomcode link `") + if not s['linked']: + cs_ = s.get('candidates') or [] + out.append(" candidates: " + ('; '.join(f"{c['target']} {c['at']} ({c['why']})" for c in cs_[:3]) + (f" +{len(cs_) - 3}" if len(cs_) > 3 else '') + if cs_ else 'no candidate')) + out.append(" read the call first; when it makes the target certain: `axiomcode link ` (a candidate is a lead, never link it by its rank)") return out @@ -913,6 +1126,7 @@ def list_links(repo, as_json=False): elif st in ('applied', 'moved', 'redundant') and l.get('line_sha') and not same(at or l['line']): st, why = 'changed', 'the line was edited since the graph was built; the next refresh re-validates it (followed if it only moved)' rows.append(dict(link=l['n'], site=f"{l['file']}:{l['line']}" + (f":{l['col']}" if l.get('col') else ''), now=at, callee=l.get('callee'), target=l['target'], + rejects=bool(l.get('not')), target_file=l.get('target_file'), status=st, reason=why, by=l.get('by'), at=l.get('at'))) for n, raw, why in bad: rows.append(dict(link=n, site='', target=raw[:80], status='malformed', reason=why)) @@ -924,7 +1138,8 @@ def list_links(repo, as_json=False): print(f"{len(rows)} asserted link(s) in {os.path.relpath(links_path(repo), repo)}:") for r in rows: where = r['site'] + (f" (now line {r['now']})" if r.get('now') and r['site'] and int(r['site'].split(':')[1]) != r['now'] else '') - print(f" {r['link']:>3}. [{r['status']}] {where} → {r['target']}" + (f" — {r['reason']}" if r['reason'] else '')) + tgt = f"NOT {r['target']} (a lead rejected: not walked)" if r.get('rejects') else r['target'] + print(f" {r['link']:>3}. [{r['status']}] {where} → {tgt}" + (f" — {r['reason']}" if r['reason'] else '')) return 0 @@ -933,6 +1148,8 @@ def main(argv): argv = [a for a in argv if a != '--json'] remove = '--remove' in argv argv = [a for a in argv if a != '--remove'] + reject = '--not' in argv + argv = [a for a in argv if a != '--not'] if '--list' in argv: argv = [a for a in argv if a != '--list']; return list_links(find_repo(argv)[0], as_json) if argv and argv[0] in ('-h', '--help', 'help'): print(USAGE); return 0 repo, args = find_repo(argv) @@ -967,21 +1184,26 @@ def main(argv): for db, rd, live in graph_dbs(repo): if not live: continue con = sqlite3.connect(f"file:{db}?mode=ro", uri=True) - try: g = Graph(con); verdicts.append((resolve(g, rd, new), g)) + try: + g = Graph(con) + if reject: + ap = {(a, b) for a, b in g.q("SELECT call_site_id, callee_method_id FROM call_edges WHERE tier = ?", TIER)} + verdicts.append((resolve_not(g, rd, new, ap), g)) + else: verdicts.append((resolve(g, rd, new), g)) except sqlite3.Error: pass - finally: pass good = [(r, g) for r, g in verdicts if r['status'] in ('applied', 'redundant')] if not good: why = sorted(verdicts, key=lambda x: (x[0]['reason'].startswith('no call is written'), x[0]['reason'].startswith('the target is not'))) print(f"axiomcode link: rejected — {why[0][0]['reason'] if why else 'no graph to check it against'}", file=sys.stderr); return 1 r, g = good[0] new.update(callee=r.get('callee_written') or callee, caller=g.display(r['caller']) if r.get('caller') else '', - target=r['target_display'], target_file=r.get('target_file') or '') + target=r['target_display'], target_file=r.get('target_file') or '', **({'not': True} if reject else {})) new['target'], new['target_file'] = stable_name(g, r['callee_id'], r['target_display'], new['target_file']) g.con.close() # the column the link resolved to is recorded even when none was given: it is what the link names from now on if r.get('col') and not new.get('col'): new['col'] = r['col']; new['ncol'] = norm_col(L[line - 1], r['col']) - dup = [l for l in links if l['file'] == f and l['line'] == line and l['target'] == new['target'] and l.get('col') == new.get('col')] + dup = [l for l in links if l['file'] == f and l['line'] == line and l['target'] == new['target'] and l.get('col') == new.get('col') + and bool(l.get('not')) == bool(new.get('not'))] links = [l for l in links if l not in dup] + [new] t0 = time.time() write_links(repo, links); _restore_bad(repo, bad) @@ -990,6 +1212,10 @@ def main(argv): note_ = next((x['reason'] for live, rs in res.values() if live for l_, x in rs if l_['file'] == f and l_['line'] == line and l_['target'] == new['target'] and l_.get('col') == new.get('col') and x['status'] in ('applied', 'moved')), '') where = f"{f}:{line}" + (f":{new['col']}" if new.get('col') else '') + if reject: + print(f"rejected the lead {where} → {new['target']}: no walk takes it from now on (the engine's row is kept; " + f"`axiomcode link {where} -` restores it) — applied to {sum(1 for v in res.values() if v[0])} graph(s) in {ms:.0f} ms") + return 0 print(f"linked {where} `{new['callee']}` → {new['target']} [asserted]" + (" (the graph already had this edge; recorded, nothing added)" if r['status'] == 'redundant' else '') + (f"; {note_}" if note_ else '') + f" — applied to {sum(1 for v in res.values() if v[0])} graph(s) in {ms:.0f} ms") return 0 diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index afbba2b4..851bd9f2 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -1346,7 +1346,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 = '69' # 69: a library-callback site is a library receiver only where a hand-back from it is summarised (ax_edges.library_receiver_sites); 68: both 67s below, from two branches; 67: spawns_fact also links the module a `python -m pkg.mod` spawn runs; 67: libcb, a library summarised to call a client member back (Python library-callbacks.dl), walked as fw_edge "library callback"; 66: imports_fact names a module from its package root (a src layout) and has each test file import the conftest.py files above it; 65: guard_only also reads a conjunctive guard (`cond and __name__ == "__main__"`); 64: guard_only, a module body's call made only under `if __name__ == "__main__":`, which the import walk does not take; 63: imports_fact resolves an import written with its extension and a workspace package's specifier, and test_method takes no JavaScript / TypeScript helper by its name; 62: state_gate, state_gate_alloc, state_call_alloc, state_call_open, state_world, the callbacks one instance was given and the allocation each caller's receiver may be (JavaScript instance-state.dl); 61: filter_run, a servlet filter a test's context holds (added to HttpSecurity in a configuration the test loads, or a component) and the test methods that send a request through it; 57: a TypeScript object literal key is a ref of entity kind OBJECT_PROPERTY_KEY, kept past a bound access on its line; 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 + IMPACT_VERSION = '70' # 70: rejected, the leads an asserted-links rejection (or a link that settles the site) takes out of the walks (ax_links.suppressed); 69: a library-callback site is a library receiver only where a hand-back from it is summarised (ax_edges.library_receiver_sites); 68: both 67s below, from two branches; 67: spawns_fact also links the module a `python -m pkg.mod` spawn runs; 67: libcb, a library summarised to call a client member back (Python library-callbacks.dl), walked as fw_edge "library callback"; 66: imports_fact names a module from its package root (a src layout) and has each test file import the conftest.py files above it; 65: guard_only also reads a conjunctive guard (`cond and __name__ == "__main__"`); 64: guard_only, a module body's call made only under `if __name__ == "__main__":`, which the import walk does not take; 63: imports_fact resolves an import written with its extension and a workspace package's specifier, and test_method takes no JavaScript / TypeScript helper by its name; 62: state_gate, state_gate_alloc, state_call_alloc, state_call_open, state_world, the callbacks one instance was given and the allocation each caller's receiver may be (JavaScript instance-state.dl); 61: filter_run, a servlet filter a test's context holds (added to HttpSecurity in a configuration the test loads, or a component) and the test methods that send a request through it; 57: a TypeScript object literal key is a ref of entity kind OBJECT_PROPERTY_KEY, kept past a bound access on its line; 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 @@ -1552,8 +1552,14 @@ class Impact: stubs = ax_edges.stub_sites(lambda s, p: g.q(s, *p)) if g.has('call_sites') else set() # a chained route link's edge sits on the line of its own verb and path, where `registration` labels it link_line = ax_registration.route_site_lines(g.q, g.site_file) + # LEADS A LINK REJECTED (ax_links.py): a site's by-name match or set member someone read and found wrong is not walked; + # the engine's row stays in graph.sqlite. `rejected` feeds the by-name rules, the set members leave `calls` here. + import ax_links + _rej, _rej_edges = ax_links.suppressed(g.q, g.site_file) + W('rejected', sorted(_rej)) W('calls', [(r['caller_id'], r['callee_method_id'], ax_edges.STUB_TIER if r['call_site_id'] in stubs else r['tier'], g.site_file(r['file_path']) if r['file_path'] else '', link_line.get(r['call_site_id'], r['start_line'] or 0)) - for r in g.q("SELECT e.call_site_id, e.caller_id, e.callee_method_id, e.tier, s.file_path, s.start_line FROM call_edges e LEFT JOIN call_sites s ON s.id = e.call_site_id WHERE e.callee_provenance = 'client' AND e.callee_method_id IS NOT NULL")] + for r in g.q("SELECT e.call_site_id, e.caller_id, e.callee_method_id, e.tier, s.file_path, s.start_line FROM call_edges e LEFT JOIN call_sites s ON s.id = e.call_site_id WHERE e.callee_provenance = 'client' AND e.callee_method_id IS NOT NULL") + if not (r['tier'] in ax_links.LEAD_TIERS and (r['caller_id'], r['callee_method_id'], g.site_file(r['file_path']) if r['file_path'] else '', r['start_line'] or 0) in _rej)] + self.protected_name_stubs()) # A CALL UNDER `if __name__ == "__main__":` DOES NOT RUN ON IMPORT. The guard is true only when the file is run # as a script, so a module body's calls inside it are real edges (the script makes them) but no hop for the @@ -2862,6 +2868,7 @@ def main(argv): unk, _unk_n = ax_links.unknown_sites(g.q, inside, g.site_file, limit=30 if as_json else 200, order=[*seeds, *sorted(direct_ids, key=g.disp), *sorted(reached, key=lambda m: (reached[m], g.disp(m)))], repo=g.repo) links_note = ax_links.note(g.q) + rejected_note = ax_links.rejected_note(g.q) # the check that means something: every printed chain hop, and every resolved direct entry, is looked up again in graph.sqlite chains = {m: I.chain(m, parent) for m in tests} # the ROUTE a record carries: a test reached through a fixture (a setUp, a @MethodSource factory) has no edge of @@ -3145,7 +3152,7 @@ def main(argv): 'byname_callers': [{'id': c, 'display': g.disp(c), 'at': g.loc(c)} for c in sorted(byname_seeds, key=by_place)], 'external': [{'at': f'{f}:{l}', 'name': n, 'how': how, **({'prose': True} if (n, f, l) in prose_hits else {})} for f, l, n, how in sorted({(r[0], int(r[1]), r[2], r[3]) for r in res.get('extbind', [])})], 'test_universe': universe, **({'tests_outside_src': dict(zip(('src', 'files'), out_src))} if out_src[1] else {}), 'verified': bad == 0, 'checked_hops': hops, 'unresolved_inside': u, - 'unknown_sites': unk, 'unknown_total': _unk_n, **({'links_note': links_note} if links_note else {}), + 'unknown_sites': unk, 'unknown_total': _unk_n, **({'links_note': links_note} if links_note else {}), **({'links_rejected': rejected_note} if rejected_note else {}), **({'why': [g.why_json(n) for n in target_why]} if want_why else {})}) if ax_evidence.on() or _dropped: if _dropped: doc['asked_again'] = {'note': ax_evidence.ask_again_note(), 'dropped': [g.loc(x[0]) if not x[4] else x[4] for x in _dropped]} diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path index ed0a0257..c64b630c 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path @@ -1007,8 +1007,8 @@ class G: # is summarised (ax_edges.library_receiver_sites); 4: both 3s below, which landed on two branches under one number; # 3 (0.1.5): on one line a JavaScript anonymous callable nests only where its name does (#1598); # 3 (0.1.6): a call written inside a mock's stub or verification is not an edge (ax_edges.stub_sites); - # 2: defines needs a written, strictly wider span (#1402, #1399); 1 was the bare mtime. - EXPORT_VERSION = '5' + # 2: defines needs a written, strictly wider span (#1402, #1399); 1 was the bare mtime. 6: rejected_edge, the leads an asserted-links rejection takes out. + EXPORT_VERSION = '6' def export(self): stamp = os.path.join(self.facts, 'stamp'); want = f"{self.db_mtime}:{self.EXPORT_VERSION}" def fresh(): @@ -1028,8 +1028,11 @@ class G: # `mock.Setup(r => r.Find(1))`): the receiver is a mock, so the real body never runs there, and walked as a call # it made every test that stubs a method a `sound` route to it. impact lists those sites as "stubs it". stubs = ax_edges.stub_sites(lambda s, p: self.q(s, *p)) if self.has('call_sites') else set() + # leads an asserted-links rejection takes out (ax_links.suppressed): a set member every site of which was rejected + import ax_links + _rej, rej_edges = ax_links.suppressed(self.q, self.site_file) edges = [(r['caller_id'], r['callee_method_id'], r['tier']) for r in self.q("SELECT call_site_id, caller_id, callee_method_id, tier FROM call_edges WHERE callee_method_id IS NOT NULL AND callee_provenance IN ('client', 'generated')") - if r['call_site_id'] not in stubs] + if r['call_site_id'] not in stubs and not (r['tier'] in ax_links.LEAD_TIERS and (r['caller_id'], r['callee_method_id']) in rej_edges)] # calls into libraries: an edge to a terminal node (a library method has no out-edges here — nothing is inferred about its # body), so `path '*' java.nio.file.Files.readAllBytes` can ask who reaches that library call edges += [(r['caller_id'], r['callee_method_id'], 'library') for r in self.q("SELECT DISTINCT caller_id, callee_method_id FROM call_edges WHERE tier = 'boundary_lib' AND callee_method_id IS NOT NULL")] @@ -1061,6 +1064,7 @@ class G: libsites = ax_edges.library_receiver_sites(lambda s, p: self.q(s, *p)) self.write('byname', sorted({(r[1], r[2]) for r in self.q("SELECT s.id, s.caller_id, s.callee_name FROM call_sites s JOIN unresolved_sites u ON u.call_site_id = s.id WHERE s.callee_name IS NOT NULL AND s.callee_name <> ''") if r[0] not in stubs and r[0] not in libsites})) self.write('named', [(r['name'], r['id']) for r in self.sym.values() if r['method_id'] and r['kind'] != 'module' and r['name']]) + self.write('rejected_edge', sorted(rej_edges)) t = f'{stamp}.{os.getpid()}.tmp'; open(t, 'w').write(want); replace_file(t, stamp) def write(self, name, rows, d=None): # written aside and renamed into place: a query reading the facts while another process (the build's warm-up, @@ -1116,7 +1120,7 @@ def _facts_rows(g, programs): """the input relations the rules read, as lists. `edge` carries g.EXTRA exactly as run_dl appends it.""" rows = {'edge': g.edges()} if 'path-opt.dl' in programs: - for n in ('byname', 'named'): + for n in ('byname', 'named', 'rejected_edge'): f = os.path.join(g.facts, n + '.facts') rows[n] = [tuple(l.rstrip('\n').split('\t')) for l in open(f)] if os.path.exists(f) else [] return rows diff --git a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl index 6c5f1978..6eddac7f 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl +++ b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl @@ -46,6 +46,8 @@ .decl calls(c:symbol, m:symbol, tier:symbol, f:symbol, l:number) .input calls .decl handoff_at(c:symbol, m:symbol, f:symbol, l:number) .input handoff_at .decl unresolved(c:symbol, n:symbol, k:symbol, f:symbol, l:number) .input unresolved +// rejected(c, m, f, l) a lead at c's site f:l to m someone rejected (axiomcode link --not; ax_links.suppressed): not walked +.decl rejected(c:symbol, m:symbol, f:symbol, l:number) .input rejected .decl named_site(c:symbol, n:symbol, k:symbol, f:symbol, l:number) .input named_site // a `new T()` of a type with no written constructor (C#): no constructor to call, so no `calls` row, but the engine knows the type (#1473) .decl implicit_new(c:symbol, t:symbol, f:symbol, l:number) .input implicit_new @@ -420,7 +422,7 @@ direct(q, c, "uses", "calls it", "one of a set", f, l) :- target(q, "method", m, // change its own [by name] dependent, so `weak_seed` held for it and the whole answer said "12 of those 12 are // reached ONLY through a dependent that is not an exact edge": everything downstream looked untrustworthy because of // one self-match. A recursive call needs no row either — the body is what is changing. -direct(q, c, "uses", "calls a method of this name (receiver not typed)", "by name", f, l) :- target(q, "method", m, _), named(n, m), unresolved(c, n, k, f, l), !ctor_kind(k), k != "stub", k != "library", !is_target_decl(q, c). +direct(q, c, "uses", "calls a method of this name (receiver not typed)", "by name", f, l) :- target(q, "method", m, _), named(n, m), unresolved(c, n, k, f, l), !ctor_kind(k), k != "stub", k != "library", !is_target_decl(q, c), !rejected(c, m, f, l). direct(q, c, "uses", "stubs a method of this name on a mock (receiver not typed): the real method does not run there", "by name", f, l) :- target(q, "method", m, _), named(n, m), unresolved(c, n, "stub", f, l), !is_target_decl(q, c). // A site whose receiver the engine traced to a package's value (`request(app).get('/x')`, ax_edges.library_receiver_sites): // the kind "library". A row of its own, listed after the untyped-receiver matches, and no seed of the closure. @@ -865,7 +867,7 @@ seed_of(q, c) :- target(q, "string", _, _), direct(q, c, _, _, cert, _, _), cert // a nested type reached through `import static Outer.Inner`, a dynamic receiver) MAY be a call to the target. It is // not a resolved edge, so it is kept out of seed / direct and enters only here, marked, so the answer can say so. .decl seed_byname(q:symbol, c:symbol) -seed_byname(q, c) :- target(q, "method", m, _), named(n, m), unresolved(c, n, k, _, _), k != "stub", k != "library", !seed(q, c). +seed_byname(q, c) :- target(q, "method", m, _), named(n, m), unresolved(c, n, k, f, l), k != "stub", k != "library", !seed(q, c), !rejected(c, m, f, l). // and a declaration handed over as a VALUE — `Depends(get_settings)`, `add_task(send_receipt, id)`, an element of // a pipeline list. `valueref` already produces the DIRECT row for these ("names it as a value — passed, stored or // registered"), and the closure stopped there: the answer named the callable that hands it over and then said the diff --git a/plugins/axiomcode/skills/axiomcode/scripts/dl/path-opt.dl b/plugins/axiomcode/skills/axiomcode/scripts/dl/path-opt.dl index 6305ccf7..fd36c7f4 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/dl/path-opt.dl +++ b/plugins/axiomcode/skills/axiomcode/scripts/dl/path-opt.dl @@ -5,11 +5,12 @@ .decl dst(q:symbol, m:symbol) .input dst .decl byname(c:symbol, n:symbol) .input byname .decl named(n:symbol, m:symbol) .input named +.decl rejected_edge(c:symbol, m:symbol) .input rejected_edge // leads an asserted-links rejection takes out (ax_links.py) // ── the optimistic closure: unresolved sites taken by name ───────────────────────────────────────────────────── .decl edge_opt(a:symbol, b:symbol, t:symbol) edge_opt(a, b, t) :- edge(a, b, t). -edge_opt(c, m, "by-name") :- byname(c, n), named(n, m). +edge_opt(c, m, "by-name") :- byname(c, n), named(n, m), !rejected_edge(c, m). .decl step_opt(q:symbol, m:symbol, d:number) step_opt(q, m, 0) :- src(q, m). step_opt(q, b, d+1) :- step_opt(q, a, d), edge_opt(a, b, _), d < 40. diff --git a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py index f8398f79..6ea7d9cf 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py @@ -4089,6 +4089,10 @@ def solve_path(rows, queries, every=False, opt=False, cap=MAX_HOP): cur.execute("CREATE TABLE edge_opt(a TEXT, b TEXT, t TEXT)") cur.execute("INSERT INTO edge_opt SELECT a, b, t FROM edge") cur.execute("INSERT INTO edge_opt SELECT DISTINCT b.c, n.m, 'by-name' FROM byname b JOIN named n ON n.n = b.n") + rej = list(rows.get('rejected_edge', ())) # leads an asserted-links rejection takes out (ax_links.py) + if rej: + cur.execute("CREATE TEMP TABLE rejected_edge(c TEXT, m TEXT)"); cur.executemany("INSERT INTO rejected_edge VALUES(?,?)", rej) + cur.execute("DELETE FROM edge_opt WHERE t = 'by-name' AND EXISTS (SELECT 1 FROM rejected_edge r WHERE r.c = edge_opt.a AND r.m = edge_opt.b)") cur.execute("CREATE INDEX eo_a ON edge_opt(a)"); cur.execute("CREATE INDEX eo_b ON edge_opt(b)") out = {n: collections.defaultdict(list) for n in ('hit', 'parent', 'hit_opt', 'parent_opt', 'between_edge', 'dist_up', 'dist')} for q, (s, d) in queries.items(): diff --git a/tests/cases/csharp/asserted-links-derive/App/Route.cs b/tests/cases/csharp/asserted-links-derive/App/Route.cs new file mode 100644 index 00000000..340b46be --- /dev/null +++ b/tests/cases/csharp/asserted-links-derive/App/Route.cs @@ -0,0 +1,44 @@ +using System.Threading.Tasks; + +namespace App +{ + public static class Route + { + public static string Handle(dynamic handler, object req) + { + return handler(req).Render(); + } + + public static string HandleLocal(dynamic handler, object req) + { + var resp = handler(req); + string text = resp.Render(); + resp.Close(); + return text; + } + + public static string HandleOptional(dynamic handler, object req) + { + return handler(req)?.Render(); + } + + public static string HandleBuilder(dynamic handler, object req) + { + return handler(req).Step().Done().Render(); + } + + public static async Task HandleAsync(dynamic handler, object req) + { + var resp = await handler(req); + return resp.Render(); + } + + public static void HandleMany(dynamic handler, object req) + { + foreach (var r in handler(req)) + { + r.Render(); + } + } + } +} diff --git a/tests/cases/csharp/asserted-links-derive/App/Views.cs b/tests/cases/csharp/asserted-links-derive/App/Views.cs new file mode 100644 index 00000000..ffa59f9f --- /dev/null +++ b/tests/cases/csharp/asserted-links-derive/App/Views.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace App +{ + public class Response + { + public string Render() => "ok"; + public void Close() { } + } + + public class Builder + { + public Builder Step() => this; + public Response Done() => new Response(); + } + + public static class Views + { + public static Response MakeResponse(object req) => new Response(); + public static Response? MaybeResponse(object req) => new Response(); + public static Builder MakeBuilder(object req) => new Builder(); + public static async Task FetchResponse(object req) { await Task.Yield(); return new Response(); } + public static IEnumerable Many(object req) => new List { new Response() }; + public static void Untyped(object req) { } + } +} diff --git a/tests/cases/csharp/asserted-links-derive/case.json b/tests/cases/csharp/asserted-links-derive/case.json new file mode 100644 index 00000000..9fa33167 --- /dev/null +++ b/tests/cases/csharp/asserted-links-derive/case.json @@ -0,0 +1,26 @@ +{"lang": "csharp", + "checks": [ + {"why": "BASE: a call through a dynamic value is unknown, and so is the call on its result", + "run": ["path", "Route.Handle", "Response.Render"], "expect_error": true, + "want": ["no chain of resolved calls connects Route.Handle and Response.Render"]}, + {"why": "declared return type: the call chained on the result resolves", + "run": ["link", "App/Route.cs:9", "Views.MakeResponse"], + "want": ["linked App/Route.cs:9:20 `handler` → Views.MakeResponse [asserted]; derived 1 edge(s) on its result (Response, declared)"]}, + {"why": "path walks the derived hop", + "run": ["path", "Route.Handle", "Response.Render"], "want": ["[asserted · call @ App/Route.cs:9] Response.Render"]}, + {"why": "var assigned from the linked call", + "run": ["link", "App/Route.cs:14", "Views.MakeResponse"], "want": ["derived 2 edge(s) on its result (Response, declared)"]}, + {"why": "T? and ?. are transparent", + "run": ["link", "App/Route.cs:22", "Views.MaybeResponse"], "want": ["derived 1 edge(s) on its result (Response, declared)"]}, + {"why": "a builder returning itself", + "run": ["link", "App/Route.cs:27", "Views.MakeBuilder"], "want": ["derived 3 edge(s) on its result (Builder, declared)"]}, + {"why": "await on Task", + "run": ["link", "App/Route.cs:32", "Views.FetchResponse"], "want": ["derived 1 edge(s) on its result (Response, declared)"]}, + {"why": "foreach over IEnumerable: the element", + "run": ["link", "App/Route.cs:38", "Views.Many"], "want": ["derived 1 edge(s) on its result (IEnumerable, declared)"]}, + {"why": "a void target derives nothing, and says so", + "run": ["link", "App/Route.cs:9", "Views.Untyped"], "want": ["return type unknown, calls on its result stay unknown"]}, + {"why": "editing the linked line drops the links and what they derived", + "edit": ["App/Route.cs", " return handler(req).Render();\n }\n\n public static string HandleLocal", " return handler(req, 1).Render();\n }\n\n public static string HandleLocal"], + "run": ["path", "Route.Handle", "Response.Render", "--fresh"], "expect_error": true, + "want": ["no chain of resolved calls connects Route.Handle and Response.Render"], "avoid": ["[asserted"]}]} diff --git a/tests/cases/java/asserted-links-derive/case.json b/tests/cases/java/asserted-links-derive/case.json new file mode 100644 index 00000000..d6633fa7 --- /dev/null +++ b/tests/cases/java/asserted-links-derive/case.json @@ -0,0 +1,11 @@ +{"lang": "java", "src": "src", + "checks": [ + {"why": "a reflective invoke linked to its target; the cast already types what follows, so nothing is derived twice", + "run": ["link", "src/pkg/Route.java:13", "Views.makeResponse"], + "want": ["linked src/pkg/Route.java:13:47 `invoke` → Views.makeResponse [asserted]; returns Response (declared); no call on its result here"]}, + {"why": "CONTROL: the engine's own edge on the cast result is kept, no asserted duplicate", + "run": ["impact", "Response.render"], + "want": ["[resolved] Route.handleLocal"], "avoid": ["[asserted] Route.handleLocal"]}, + {"why": "a chained call after a cast resolved by the engine: no duplicate derived edge", + "run": ["link", "src/pkg/Route.java:9", "Views.makeResponse"], + "want": ["no call on its result here"]}]} diff --git a/tests/cases/java/asserted-links-derive/src/pkg/Builder.java b/tests/cases/java/asserted-links-derive/src/pkg/Builder.java new file mode 100644 index 00000000..b924342c --- /dev/null +++ b/tests/cases/java/asserted-links-derive/src/pkg/Builder.java @@ -0,0 +1,6 @@ +package pkg; + +public class Builder { + public Builder step() { return this; } + public Response done() { return new Response(); } +} diff --git a/tests/cases/java/asserted-links-derive/src/pkg/Response.java b/tests/cases/java/asserted-links-derive/src/pkg/Response.java new file mode 100644 index 00000000..b6a5ff21 --- /dev/null +++ b/tests/cases/java/asserted-links-derive/src/pkg/Response.java @@ -0,0 +1,6 @@ +package pkg; + +public class Response { + public String render() { return "ok"; } + public void close() { } +} diff --git a/tests/cases/java/asserted-links-derive/src/pkg/Route.java b/tests/cases/java/asserted-links-derive/src/pkg/Route.java new file mode 100644 index 00000000..bfed1f7f --- /dev/null +++ b/tests/cases/java/asserted-links-derive/src/pkg/Route.java @@ -0,0 +1,29 @@ +package pkg; + +import java.lang.reflect.Method; + +public class Route { + static Method find(String name) throws Exception { return Views.class.getMethod(name, Object.class); } + + public static String handle(String name, Object req) throws Exception { + return ((Response) find(name).invoke(null, req)).render(); + } + + public static String handleLocal(String name, Object req) throws Exception { + Response resp = (Response) find(name).invoke(null, req); + String text = resp.render(); + resp.close(); + return text; + } + + public static String handleVar(String name, Object req) throws Exception { + var resp = find(name).invoke(null, req); + return ((Response) resp).render(); + } + + public static Response handleCtor(java.util.function.Supplier make) { + Response r = make.get(); + r.close(); + return r; + } +} diff --git a/tests/cases/java/asserted-links-derive/src/pkg/Views.java b/tests/cases/java/asserted-links-derive/src/pkg/Views.java new file mode 100644 index 00000000..c0202007 --- /dev/null +++ b/tests/cases/java/asserted-links-derive/src/pkg/Views.java @@ -0,0 +1,12 @@ +package pkg; + +import java.util.List; +import java.util.Optional; + +public class Views { + public static Response makeResponse(Object req) { return new Response(); } + public static Optional maybeResponse(Object req) { return Optional.of(new Response()); } + public static Builder makeBuilder(Object req) { return new Builder(); } + public static void untyped(Object req) { } + public static List many(Object req) { return List.of(new Response()); } +} diff --git a/tests/cases/javascript/asserted-links-derive/case.json b/tests/cases/javascript/asserted-links-derive/case.json new file mode 100644 index 00000000..f326b350 --- /dev/null +++ b/tests/cases/javascript/asserted-links-derive/case.json @@ -0,0 +1,24 @@ +{"lang": "javascript", "src": "src", + "checks": [ + {"why": "BASE: the handler read by a key built at run time is unknown, and so is the call on its result", + "run": ["path", "handle", "Response.render"], "expect_error": true, + "want": ["no chain of resolved calls connects handle and Response.render"]}, + {"why": "JSDoc @returns types the result: the chained call resolves", + "run": ["link", "src/route.js:5", "makeResponse"], + "want": ["linked src/route.js:5:10 `handler` → makeResponse [asserted]; derived 1 edge(s) on its result (Response, JSDoc)"]}, + {"why": "path walks the derived hop", + "run": ["path", "handle", "Response.render"], "want": ["[asserted · call @ src/route.js:5] Response.render"]}, + {"why": "a const assigned from the linked call", + "run": ["link", "src/route.js:10", "makeResponse"], "want": ["derived 2 edge(s) on its result (Response, JSDoc)"]}, + {"why": "?. is transparent", + "run": ["link", "src/route.js:18", "makeResponse"], "want": ["derived 1 edge(s) on its result (Response, JSDoc)"]}, + {"why": "no JSDoc: the type every return constructs, then a builder returning this", + "run": ["link", "src/route.js:23", "makeBuilder"], "want": ["derived 3 edge(s) on its result (Builder, inferred from its returns)"]}, + {"why": "CONTROL: a JSDoc above ANOTHER function is not this one's; no return type, nothing derived", + "run": ["link", "src/route.js:28", "untyped"], "want": ["return type unknown, calls on its result stay unknown"]}, + {"why": "await on an async function's returned value", + "run": ["link", "src/route.js:34", "fetchResponse"], "want": ["derived 1 edge(s) on its result (Response, inferred from its returns)"]}, + {"why": "editing the linked line drops the link and what it derived", + "edit": ["src/route.js", " return handler(req).render();\n}\n\nexport function handleLocal", " return handler(req, 1).render();\n}\n\nexport function handleLocal"], + "run": ["path", "handle", "Response.render", "--fresh"], "expect_error": true, + "want": ["no chain of resolved calls connects handle and Response.render"], "avoid": ["[asserted"]}]} diff --git a/tests/cases/javascript/asserted-links-derive/src/route.js b/tests/cases/javascript/asserted-links-derive/src/route.js new file mode 100644 index 00000000..950320d7 --- /dev/null +++ b/tests/cases/javascript/asserted-links-derive/src/route.js @@ -0,0 +1,36 @@ +import * as views from './views'; + +export function handle(name, req) { + const handler = views['make' + name]; + return handler(req).render(); +} + +export function handleLocal(name, req) { + const handler = views['make' + name]; + const resp = handler(req); + const text = resp.render(); + resp.close(); + return text; +} + +export function handleOptional(name, req) { + const handler = views['make' + name]; + return handler(req)?.render(); +} + +export function handleBuilder(name, req) { + const handler = views['make' + name]; + return handler(req).step().done().render(); +} + +export function handleUntyped(name, req) { + const handler = views['un' + name]; + const out = handler(req); + return out.render(); +} + +export async function handleAsync(name, req) { + const handler = views['fetch' + name]; + const resp = await handler(req); + return resp.render(); +} diff --git a/tests/cases/javascript/asserted-links-derive/src/views.js b/tests/cases/javascript/asserted-links-derive/src/views.js new file mode 100644 index 00000000..aad7e73d --- /dev/null +++ b/tests/cases/javascript/asserted-links-derive/src/views.js @@ -0,0 +1,20 @@ +export class Response { + render() { return 'ok'; } + close() {} +} + +export class Builder { + step() { return this; } + done() { return new Response(); } +} + +/** @returns {Response} */ +export function makeResponse(req) { return build(req); } + +export function makeBuilder(req) { return new Builder(); } + +export function untyped(req) { return req; } + +export async function fetchResponse(req) { return new Response(); } + +function build(req) { return new Response(); } diff --git a/tests/cases/python/asserted-links/app/dispatch.py b/tests/cases/python/asserted-links/app/dispatch.py index f97d80f6..3b6b5c50 100644 --- a/tests/cases/python/asserted-links/app/dispatch.py +++ b/tests/cases/python/asserted-links/app/dispatch.py @@ -21,3 +21,7 @@ def save_and_audit(doc): def copy_doc(doc): return doc.copy() + + +def refresh(doc, store): + return store.on_load(doc) diff --git a/tests/cases/python/asserted-links/case.json b/tests/cases/python/asserted-links/case.json index 34bf1cd6..c1a2b8ff 100644 --- a/tests/cases/python/asserted-links/case.json +++ b/tests/cases/python/asserted-links/case.json @@ -159,6 +159,137 @@ "no chain of resolved calls connects test_table and on_load" ] }, + { + "why": "FRONT DOOR: a by-name lead alone is listed, and its site is to resolve, with the declaration of its name as a candidate", + "env": { + "AXIOMCODE_FRONT": "1" + }, + "run": [ + "impact", + "on_load" + ], + "want": [ + "by name · refresh", + "to resolve:", + "app/dispatch.py:27:18 return store.on_load(doc)", + "candidates: on_load app/handlers.py:5" + ] + }, + { + "why": "a computed name's constant prefix gives candidates", + "run": [ + "impact", + "purge", + "--unknown" + ], + "want": [ + "candidates: on_save app/handlers.py:1 (named on_…, the constant part of the computed name)" + ] + }, + { + "why": "a lead can be rejected: it is no longer walked", + "run": [ + "link", + "app/dispatch.py:27", + "--not", + "on_load" + ], + "want": [ + "rejected the lead app/dispatch.py:27:18 → on_load" + ] + }, + { + "why": "…impact no longer lists the rejected lead", + "run": [ + "impact", + "on_load" + ], + "avoid": [ + "refresh app/dispatch.py:27" + ] + }, + { + "why": "…and the list shows it as a rejection", + "run": [ + "link" + ], + "want": [ + "→ NOT on_load (a lead rejected: not walked)" + ] + }, + { + "why": "CONTROL: an edge the engine resolved cannot be rejected", + "run": [ + "link", + "app/dispatch.py:19", + "--not", + "audit" + ], + "expect_error": true, + "want": [ + "the engine resolved this call; if it is wrong that is an engine defect — not hidden" + ] + }, + { + "why": "removing the rejection restores the lead", + "run": [ + "link", + "app/dispatch.py:27:18", + "-" + ], + "want": [ + "removed 1 link(s)" + ] + }, + { + "why": "…it is walked again", + "run": [ + "impact", + "on_load" + ], + "want": [ + "[by name] refresh app/dispatch.py:27" + ] + }, + { + "why": "a lead and an asserted link at the same site: the site is listed ONCE, as confirmed", + "run": [ + "link", + "app/dispatch.py:27", + "on_load" + ], + "want": [ + "linked app/dispatch.py:27:18 `on_load` → on_load [asserted]" + ] + }, + { + "why": "FRONT DOOR: confirmed, not a lead; the settled site is not to resolve", + "env": { + "AXIOMCODE_FRONT": "1" + }, + "run": [ + "impact", + "on_load" + ], + "want": [ + "asserted · refresh" + ], + "avoid": [ + "by name · refresh", + "app/dispatch.py:27:18 return store.on_load(doc) [untyped" + ] + }, + { + "why": "clean up the site link before the edit steps", + "run": [ + "link", + "app/dispatch.py:27:18", + "-" + ], + "want": [ + "removed" + ] + }, { "why": "lines inserted above the site: after the rebuild the link follows the line by its text", "edit": [ diff --git a/tests/cases/typescript/asserted-links-derive/case.json b/tests/cases/typescript/asserted-links-derive/case.json new file mode 100644 index 00000000..a5ef3915 --- /dev/null +++ b/tests/cases/typescript/asserted-links-derive/case.json @@ -0,0 +1,27 @@ +{"lang": "typescript", "src": "src", + "checks": [ + {"why": "BASE: the handler read by a computed key is unknown, and so is the call on its result", + "run": ["path", "handle", "Response.render"], "expect_error": true, + "want": ["no chain of resolved calls connects handle and Response.render"]}, + {"why": "declared return type: the call chained on the result resolves", + "run": ["link", "src/route.ts:5", "makeResponse"], + "want": ["linked src/route.ts:5:10 `handler` → makeResponse [asserted]; derived 1 edge(s) on its result (Response, declared)"]}, + {"why": "path walks the derived hop", + "run": ["path", "handle", "Response.render"], + "want": ["[asserted · call @ src/route.ts:5] Response.render"]}, + {"why": "a const assigned from the linked call", + "run": ["link", "src/route.ts:10", "makeResponse"], "want": ["derived 2 edge(s) on its result (Response, declared)"]}, + {"why": "T | undefined and ?. are transparent", + "run": ["link", "src/route.ts:18", "maybeResponse"], "want": ["derived 1 edge(s) on its result (Response, declared)"]}, + {"why": "no declared type: the type every return constructs, then a builder returning this", + "run": ["link", "src/route.ts:23", "makeBuilder"], "want": ["derived 3 edge(s) on its result (Builder, inferred from its returns)"]}, + {"why": "no return type at all: nothing derived, and it says so", + "run": ["link", "src/route.ts:28", "untyped"], "want": ["return type unknown, calls on its result stay unknown"]}, + {"why": "await on a Promise", + "run": ["link", "src/route.ts:34", "fetchResponse"], "want": ["derived 1 edge(s) on its result (Response, declared)"]}, + {"why": "for…of over a T[]: the element", + "run": ["link", "src/route.ts:40", "many"], "want": ["derived 1 edge(s) on its result (Response[], declared)"]}, + {"why": "editing the linked line drops the link and what it derived", + "edit": ["src/route.ts", " return handler(req).render();\n}\n\nexport function handleLocal", " return handler(req, 1).render();\n}\n\nexport function handleLocal"], + "run": ["path", "handle", "Response.render", "--fresh"], "expect_error": true, + "want": ["no chain of resolved calls connects handle and Response.render"], "avoid": ["[asserted"]}]} diff --git a/tests/cases/typescript/asserted-links-derive/src/route.ts b/tests/cases/typescript/asserted-links-derive/src/route.ts new file mode 100644 index 00000000..1369a747 --- /dev/null +++ b/tests/cases/typescript/asserted-links-derive/src/route.ts @@ -0,0 +1,43 @@ +import * as views from './views'; + +export function handle(name: string, req: unknown) { + const handler = (views as any)[name]; + return handler(req).render(); +} + +export function handleLocal(name: string, req: unknown) { + const handler = (views as any)[name]; + const resp = handler(req); + const text = resp.render(); + resp.close(); + return text; +} + +export function handleOptional(name: string, req: unknown) { + const handler = (views as any)[name]; + return handler(req)?.render(); +} + +export function handleBuilder(name: string, req: unknown) { + const handler = (views as any)[name]; + return handler(req).step().done().render(); +} + +export function handleUntyped(name: string, req: unknown) { + const handler = (views as any)[name]; + const out = handler(req); + return out.render(); +} + +export async function handleAsync(name: string, req: unknown) { + const handler = (views as any)[name]; + const resp = await handler(req); + return resp.render(); +} + +export function handleMany(name: string, req: unknown) { + const handler = (views as any)[name]; + for (const r of handler(req)) { + r.render(); + } +} diff --git a/tests/cases/typescript/asserted-links-derive/src/views.ts b/tests/cases/typescript/asserted-links-derive/src/views.ts new file mode 100644 index 00000000..5452a5b9 --- /dev/null +++ b/tests/cases/typescript/asserted-links-derive/src/views.ts @@ -0,0 +1,16 @@ +export class Response { + render(): string { return 'ok'; } + close(): void {} +} + +export class Builder { + step(): Builder { return this; } + done(): Response { return new Response(); } +} + +export function makeResponse(req: unknown): Response { return new Response(); } +export function maybeResponse(req: unknown): Response | undefined { return new Response(); } +export async function fetchResponse(req: unknown): Promise { return new Response(); } +export function makeBuilder(req: unknown) { return new Builder(); } +export function untyped(req: unknown) { return req; } +export function many(req: unknown): Response[] { return [new Response()]; } diff --git a/tests/run.py b/tests/run.py index fe077e91..90b9be30 100755 --- a/tests/run.py +++ b/tests/run.py @@ -22,6 +22,7 @@ CONCATENATED, so no substring can express "this must not be inside the document" — which is how a `note:` line sat in --json for every name declared as both a field and a method. "expect_error": true a non-zero exit is the answer, not a fault (`path` exits 1 when it finds no chain). + "env": {name: value} the check runs with these environment variables set (AXIOMCODE_FRONT=1 for the front door). "edit": [file, old, new] the case edits that file before the check runs; edited files and a links file the case wrote are restored when the case ends. "pending": "" the check states behaviour the tool does NOT have yet. It still RUNS. Failing prints PEND and @@ -70,7 +71,9 @@ def print(*a, flush=False, **k): builtins.print(*a, file=buf, **k) edited.setdefault(fp, txt) if old not in txt: print(f"FAIL {l}/{name}: the edit's old text is not in {ef}"); fail += 1; continue open(fp, 'w').write(txt.replace(old, new, 1)) - out = subprocess.run(['bash', AX] + [a.replace('{repo}', path) for a in ch['run']] + ([path] if ch['run'][0] != 'index' else []), capture_output=True, text=True) + # "env": {…} — the check runs with these set (AXIOMCODE_FRONT=1: the answer the installed command and MCP give) + out = subprocess.run(['bash', AX] + [a.replace('{repo}', path) for a in ch['run']] + ([path] if ch['run'][0] != 'index' else []), capture_output=True, text=True, + env=dict(os.environ, **ch['env']) if ch.get('env') else None) text = out.stdout + out.stderr # a [text] row quoting this case.json is the spec read back (a name no graph declares is searched as text, and the # case file lies in the searched tree): its own `avoid` strings there are not the tool's answer From 98a78e958532dfbe5d71b974cc24e0a4b1735c90 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Sat, 10 Oct 2026 03:00:16 -0700 Subject: [PATCH 10/11] feat(links): a construction links only to its type; not: rejects a lead; candidates ranked by the name called (#1892) A decorator factory's call and the decoration applying its result are one call at one column. Stress harness scenarios S1-S13 pass in all five languages where the subject can exhibit them. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- plugins/axiomcode/mcp/server.py | 5 ++-- plugins/axiomcode/skills/axiomcode/SKILL.md | 27 ++++++++++++------- .../skills/axiomcode/scripts/ax_links.py | 17 +++++++++--- skills/axiomcode/SKILL.md | 27 ++++++++++++------- 4 files changed, 50 insertions(+), 26 deletions(-) diff --git a/plugins/axiomcode/mcp/server.py b/plugins/axiomcode/mcp/server.py index 5081fcbb..a4f5d2c4 100755 --- a/plugins/axiomcode/mcp/server.py +++ b/plugins/axiomcode/mcp/server.py @@ -248,8 +248,9 @@ def link(site: str = '', target: str = '') -> str: """Record where an unresolved call lands, when you have read the code and the target is CERTAIN: site is the call's file:line as an answer's `unknown:` block lists it, target the declaration it reaches (Owner.method, function, or its file:line). impact, path and tests then walk the edge, labelled [asserted]. With no arguments: every link and - whether the graph took it (a link whose line changed is dropped, never trusted). target "-" removes the site's links. - Never link a guess.""" + whether the graph took it (a link whose line changed is dropped, never trusted). target "-" removes the site's links; + target "not:" rejects a lead (a by-name or one-of-a-set guess) at that site, which is then not walked. + Never link a guess, and never link a candidate for its rank alone.""" return plain(run(['link'] + ([site] if site.strip() else []) + ([target] if site.strip() and target.strip() else []) + [os.getcwd()])) @srv.tool() diff --git a/plugins/axiomcode/skills/axiomcode/SKILL.md b/plugins/axiomcode/skills/axiomcode/SKILL.md index c92f8060..838be50f 100644 --- a/plugins/axiomcode/skills/axiomcode/SKILL.md +++ b/plugins/axiomcode/skills/axiomcode/SKILL.md @@ -62,16 +62,23 @@ Example: `tests()`. It is a lower bound: a test reached only through reflection ## link -An answer that stops at a call the graph could not resolve lists it under `unknown:` — the site as `file:line`, the -call as written and why the engine could not follow it (a value from `getattr`, a handler table, reflection, a -callback). When the task depends on one of those sites, read the code; if it makes the target CERTAIN, record it: -`link(site="app/dispatch.py:6", target="on_save")`, or `axiomcode link app/dispatch.py:6 on_save` from the shell. -From then on impact, path and tests walk that edge, labelled `[asserted]`, never `resolved`. The links are kept in -`axiomcode-links.tsv` at the repository root, which is worth committing. `link()` with no arguments lists them and -whether the graph took each one; `axiomcode link -` removes one. A link is refused when the call written -there names a different declaration, or the target is not one; when the line it was made on is edited, it is -dropped and listed as stale, and the site is unknown again. Never link a guess: an asserted edge is trusted by every -answer after it. +Answers are in three parts. CONFIRMED places are backed by an edge: `resolved` by the engine, or `asserted` by a link — +act on them. LEADS are reached only through a guess (`by name`, `by key`, `one of a set`, `text`) — check each before +relying on it. TO RESOLVE lists the calls the answer stopped at: the site as `file:line:col`, the call as written, why +the engine could not follow it (a value from `getattr`, a handler table, reflection, a callback) and the graph's +candidate targets with their `file:line`. + +When the task depends on one of those sites, read the call. Only if the code makes the target CERTAIN, record it: +`link(site="app/dispatch.py:6:12", target="on_save")`, or `axiomcode link app/dispatch.py:6:12 on_save` from the shell. +A candidate is a lead: confirm it by reading the call, never link one because it is ranked first. From then on impact, +path and tests walk that edge, labelled `[asserted]`, never `resolved`; when the target declares a return type, the +calls made on its result (chained, or on a variable assigned from it) resolve too. When a lead at a site is wrong, +reject it: `link(site, "not:")` — it is no longer walked; only a guess can be rejected, never an edge the +engine resolved. The links are kept in `axiomcode-links.tsv` at the repository root, which is worth committing. +`link()` with no arguments lists them and whether the graph took each one; `axiomcode link -` removes +one. A link is refused when the call written there names a different declaration, or the target is not one; when +the line it was made on is edited, it is dropped and listed as stale, and the site is to resolve again. Never link a +guess: an asserted edge is trusted by every answer after it. ## context diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_links.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_links.py index 0efa6f4d..65988c8c 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_links.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_links.py @@ -53,6 +53,7 @@ # a call written on a receiver (`x.m()`): its callee as written is a member name, not a value MEMBER_KINDS = {'METHOD_CALL', 'SELF_CALL', 'SUPER_CALL', 'CHAINED_CALL', 'OPTIONAL_CALL', 'PROPERTY_READ', 'PROPERTY_WRITE', 'CONTEXT_MANAGER', 'ITERATION_PROTOCOL', 'BUILTIN_PROTOCOL', 'DECORATOR_ATTRIBUTE'} +CTOR_KINDS = {'new', 'CONSTRUCTOR_CALL', 'anon_new', 'METACLASS_CREATION', 'object_creation', 'OBJECT_CREATION'} CTOR_NAMES = {'__init__', '', 'constructor', '.ctor', '__new__'} @@ -306,6 +307,9 @@ def consistent(g, site, tname, tkind, towner): owner_simple = (towner or '').split('.')[-1] if callee and callee == tname: return '' if callee and (tname in CTOR_NAMES or tkind in ('constructor', 'class')) and callee in (owner_simple, tname): return '' + # a CONSTRUCTION names its type: `new Error(…)` builds an Error whatever the graph declares, never some other function + if site['kind'] in CTOR_KINDS: + return f"the call constructs `{site['callee']}`; a construction is linked only to that type's constructor" if site['kind'] in VALUE_KINDS: return '' if callee in REFLECTIVE: return '' rsn = g.reason(site['id']) @@ -396,6 +400,10 @@ def resolve(g, reader, link): un = [s for s in good if g.unresolved(s['id']) or is_value_lib(g, s['id'])] named = [s for s in good if (s['callee'] or '').split('.')[-1] == tname] good = named if len(named) == 1 else un if un else good + if len(good) > 1 and len({(x['callee'], x['col']) for x in good}) == 1: + # one call written once and recorded twice (a decorator factory's call and the decoration applying its result): + # the call itself + good = sorted(good, key=lambda x: (x['kind'] or '').endswith('APPLICATION'))[:1] if len(good) > 1: # NEVER CHOOSE between two calls the link could mean (`a.run() + b.run()`, `f(a)(b)`): the column says which which = ', '.join(f"`{x['callee'] or '?'}` at column {x['col']}" for x in sorted(good, key=lambda x: x['col'])) @@ -929,6 +937,10 @@ def add(mid, why): nm = (callee or '').split('.')[-1] L = reader.lines(file_) if reader and file_ else None cs = q("SELECT name, file, line, end_line, signature FROM symbols WHERE id = ? LIMIT 1", caller) + # declarations of the callee's own name (a member call on an untyped receiver) + if nm: + for (m,) in q("SELECT method_id FROM symbols WHERE name = ? AND method_id IS NOT NULL AND kind <> 'module' LIMIT 10", nm): + add(m, "a declaration of the name called") # a parameter called: what the callers pass in that position if nm and cs and L is not None: cname, cf, cl, ce, sig = cs[0] @@ -967,10 +979,6 @@ def add(mid, why): for v in sorted(vals): for (m,) in q("SELECT method_id FROM symbols WHERE name = ? AND method_id IS NOT NULL AND kind NOT IN ('module', 'class') LIMIT 3", v): add(m, "handed over as a value in this file") - # declarations of the callee's own name (a member call on an untyped receiver) - if nm: - for (m,) in q("SELECT method_id FROM symbols WHERE name = ? AND method_id IS NOT NULL AND kind <> 'module' LIMIT 10", nm): - add(m, "a declaration of the name called") return out @@ -1173,6 +1181,7 @@ def main(argv): if len(args) < 2: print(USAGE, file=sys.stderr); return 2 target = args[1]; callee = args[2] if len(args) > 2 else '' + if target.startswith('not:'): reject, target = True, target[4:] reader = Reader(repo); L = reader.lines(f) if L is None or not (0 < line <= len(L)): print(f"axiomcode link: rejected — {f}:{line} is not a line of a file in this repository", file=sys.stderr); return 1 diff --git a/skills/axiomcode/SKILL.md b/skills/axiomcode/SKILL.md index d7b3045e..ba96f81f 100644 --- a/skills/axiomcode/SKILL.md +++ b/skills/axiomcode/SKILL.md @@ -59,16 +59,23 @@ Example: `tests()`. It is a lower bound: a test reached only through reflection ## link -An answer that stops at a call the graph could not resolve lists it under `unknown:` — the site as `file:line`, the -call as written and why the engine could not follow it (a value from `getattr`, a handler table, reflection, a -callback). When the task depends on one of those sites, read the code; if it makes the target CERTAIN, record it: -`link(site="app/dispatch.py:6", target="on_save")`, or `axiomcode link app/dispatch.py:6 on_save` from the shell. -From then on impact, path and tests walk that edge, labelled `[asserted]`, never `resolved`. The links are kept in -`axiomcode-links.tsv` at the repository root, which is worth committing. `link()` with no arguments lists them and -whether the graph took each one; `axiomcode link -` removes one. A link is refused when the call written -there names a different declaration, or the target is not one; when the line it was made on is edited, it is -dropped and listed as stale, and the site is unknown again. Never link a guess: an asserted edge is trusted by every -answer after it. +Answers are in three parts. CONFIRMED places are backed by an edge: `resolved` by the engine, or `asserted` by a link — +act on them. LEADS are reached only through a guess (`by name`, `by key`, `one of a set`, `text`) — check each before +relying on it. TO RESOLVE lists the calls the answer stopped at: the site as `file:line:col`, the call as written, why +the engine could not follow it (a value from `getattr`, a handler table, reflection, a callback) and the graph's +candidate targets with their `file:line`. + +When the task depends on one of those sites, read the call. Only if the code makes the target CERTAIN, record it: +`link(site="app/dispatch.py:6:12", target="on_save")`, or `axiomcode link app/dispatch.py:6:12 on_save` from the shell. +A candidate is a lead: confirm it by reading the call, never link one because it is ranked first. From then on impact, +path and tests walk that edge, labelled `[asserted]`, never `resolved`; when the target declares a return type, the +calls made on its result (chained, or on a variable assigned from it) resolve too. When a lead at a site is wrong, +reject it: `link(site, "not:")` — it is no longer walked; only a guess can be rejected, never an edge the +engine resolved. The links are kept in `axiomcode-links.tsv` at the repository root, which is worth committing. +`link()` with no arguments lists them and whether the graph took each one; `axiomcode link -` removes +one. A link is refused when the call written there names a different declaration, or the target is not one; when +the line it was made on is edited, it is dropped and listed as stale, and the site is to resolve again. Never link a +guess: an asserted edge is trusted by every answer after it. ## context From ac21c00afcc98ce40971f57299b95489f53c8a5f Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Sat, 10 Oct 2026 09:18:26 -0700 Subject: [PATCH 11/11] docs(readme): the axiomcode link row in the CLI table --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index c388a185..4879d548 100644 --- a/README.md +++ b/README.md @@ -318,6 +318,7 @@ left to your own search: bring the name you found to these commands. | `axiomcode impact` | the same for the declarations your uncommitted edits changed; the answer starts with `your edits:` | | `axiomcode path ` | how A reaches B: every hop of the call chain, with the code at each call | | `axiomcode tests` | the tests your uncommitted edits reach, and a last `run:` line with the command that runs them | +| `axiomcode link ` | record where a call the graph could not resolve lands (kept in `axiomcode-links.tsv`); impact, path and tests then walk it, labelled `[asserted]`. Alone, lists the links and whether each was applied | | `axiomcode index` | build the graph explicitly (the first query builds it too); `--lang`, `--src` and `--library` narrow it | A name is written the way it appears in the code: `Owner.method`, `method`, `Type`, `Owner.field`, or