From bf4019e6f77c9591ae32745d5ea7ac3bd5737215 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:31:15 -0700 Subject: [PATCH] typescript: cross-process remote_edge for HTTP routes, and context lists remote hops TypeScript had no framework-behavior phase, so no TS bundle held an ext_remote_edge row and impact/path stopped at the send. destinations.dl joins an HTTP client (fetch, an axios-style client or its create()d instance) to the route that serves it (router calls, @Controller/@Get), reading the path from a literal, a template, a constant (local or imported) or a const config object. Verb must agree; the route with the fewest differing segments wins. Unjoined halves are reported as remote_unserved / remote_unsent / remote_undetermined. Framework names live in config-resolution/knobs.dl. context now prints ext_remote_edge rows as their own tier (transport, destination, confidence), never merged into call hops. impact's test closure walks a remote hop from sender to handler on its own `remote` rung, so test-impact selects the test that drives the client. Part of #1293 Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> Co-Authored-By: Claude --- .../src/client/axios.d.ts | 13 + .../src/client/client.ts | 34 ++ .../src/server/orders.ts | 26 ++ .../src/server/reports.controller.ts | 16 + .../src/shared/config.ts | 6 + .../src/shared/paths.ts | 2 + .../src/tsconfig.json | 10 + .../78-cross-process-destinations.edges | 25 ++ .../78-cross-process-destinations.entries | 40 ++ .../78-cross-process-destinations.fields | 1 + ...8-cross-process-destinations.fields-oracle | 8 + ...8-cross-process-destinations.known-missing | 11 + .../78-cross-process-destinations.oracle | 4 + .../78-cross-process-destinations.type-use | 7 + ...78-cross-process-destinations.types-oracle | 9 + graph/test/typescript/tools/entry_report.py | 16 + .../engine/config-resolution/knobs.dl | 71 +++ .../engine/framework-behavior/destinations.dl | 415 ++++++++++++++++++ graph/typescript/souffle/decls_all.dl | 4 + graph/typescript/souffle/export_manifest.tsv | 4 + .../axiomcode/scripts/axiomcode-context | 30 ++ .../skills/axiomcode/scripts/axiomcode-impact | 14 +- .../skills/axiomcode/scripts/dl/impact.dl | 9 +- .../cases/java/cross-process-route/case.json | 22 + tests/cases/java/cross-process-route/new.java | 21 + tests/cases/java/cross-process-route/old.java | 21 + .../src/app/ItemClient.java | 13 + .../src/app/ItemController.java | 21 + .../src/test/ItemClientTest.java | 12 + .../typescript/cross-process-route/case.json | 24 + .../typescript/cross-process-route/new.ts | 22 + .../typescript/cross-process-route/old.ts | 22 + .../cross-process-route/src/client/axios.d.ts | 8 + .../src/client/client.test.ts | 6 + .../cross-process-route/src/client/client.ts | 8 + .../cross-process-route/src/server/orders.ts | 22 + .../cross-process-route/src/shared/paths.ts | 1 + 37 files changed, 993 insertions(+), 5 deletions(-) create mode 100644 graph/test/typescript/cases/78-cross-process-destinations/src/client/axios.d.ts create mode 100644 graph/test/typescript/cases/78-cross-process-destinations/src/client/client.ts create mode 100644 graph/test/typescript/cases/78-cross-process-destinations/src/server/orders.ts create mode 100644 graph/test/typescript/cases/78-cross-process-destinations/src/server/reports.controller.ts create mode 100644 graph/test/typescript/cases/78-cross-process-destinations/src/shared/config.ts create mode 100644 graph/test/typescript/cases/78-cross-process-destinations/src/shared/paths.ts create mode 100644 graph/test/typescript/cases/78-cross-process-destinations/src/tsconfig.json create mode 100644 graph/test/typescript/expected/78-cross-process-destinations.edges create mode 100644 graph/test/typescript/expected/78-cross-process-destinations.entries create mode 100644 graph/test/typescript/expected/78-cross-process-destinations.fields create mode 100644 graph/test/typescript/expected/78-cross-process-destinations.fields-oracle create mode 100644 graph/test/typescript/expected/78-cross-process-destinations.known-missing create mode 100644 graph/test/typescript/expected/78-cross-process-destinations.oracle create mode 100644 graph/test/typescript/expected/78-cross-process-destinations.type-use create mode 100644 graph/test/typescript/expected/78-cross-process-destinations.types-oracle create mode 100644 graph/typescript/engine/framework-behavior/destinations.dl create mode 100644 tests/cases/java/cross-process-route/case.json create mode 100644 tests/cases/java/cross-process-route/new.java create mode 100644 tests/cases/java/cross-process-route/old.java create mode 100644 tests/cases/java/cross-process-route/src/app/ItemClient.java create mode 100644 tests/cases/java/cross-process-route/src/app/ItemController.java create mode 100644 tests/cases/java/cross-process-route/src/test/ItemClientTest.java create mode 100644 tests/cases/typescript/cross-process-route/case.json create mode 100644 tests/cases/typescript/cross-process-route/new.ts create mode 100644 tests/cases/typescript/cross-process-route/old.ts create mode 100644 tests/cases/typescript/cross-process-route/src/client/axios.d.ts create mode 100644 tests/cases/typescript/cross-process-route/src/client/client.test.ts create mode 100644 tests/cases/typescript/cross-process-route/src/client/client.ts create mode 100644 tests/cases/typescript/cross-process-route/src/server/orders.ts create mode 100644 tests/cases/typescript/cross-process-route/src/shared/paths.ts diff --git a/graph/test/typescript/cases/78-cross-process-destinations/src/client/axios.d.ts b/graph/test/typescript/cases/78-cross-process-destinations/src/client/axios.d.ts new file mode 100644 index 000000000..a26f70aff --- /dev/null +++ b/graph/test/typescript/cases/78-cross-process-destinations/src/client/axios.d.ts @@ -0,0 +1,13 @@ +// The client package, as its types describe it. The rules key on the import's +// specifier, so this stands in for the package without changing what is read. +declare module 'axios' { + interface HttpInstance { + get(url: string, config?: unknown): Promise; + post(url: string, data?: unknown, config?: unknown): Promise; + } + interface HttpStatic extends HttpInstance { + create(config: { baseURL?: string }): HttpInstance; + } + const axios: HttpStatic; + export default axios; +} diff --git a/graph/test/typescript/cases/78-cross-process-destinations/src/client/client.ts b/graph/test/typescript/cases/78-cross-process-destinations/src/client/client.ts new file mode 100644 index 000000000..1b5022775 --- /dev/null +++ b/graph/test/typescript/cases/78-cross-process-destinations/src/client/client.ts @@ -0,0 +1,34 @@ +// The client end. None of these calls the server: the only thing tying a send to its +// handler is the route both ends spell, so every edge below is a remote_edge, never a call. +import axios from 'axios'; +import { ORDERS } from '../shared/paths'; +import { config } from '../shared/config'; + +// literal, built by a template: GET /api/orders/{} -> getOrder (a placeholder stood in for :id) +export async function fetchOrder(id: string) { return fetch(`/api/orders/${id}`); } + +// constant: POST /api/orders -> createOrder, and NOT listOrders, which serves GET +export async function placeOrder(body: unknown) { return axios.post(ORDERS, body); } + +// constant, GET by default: -> listOrders only +export async function listAll() { return fetch(ORDERS); } + +// configured base address plus a segment: GET /api/reports/{} -> ReportsController.daily +export async function dailyReport(day: string) { return axios.get(`${config.reportsUrl}/${day}`); } + +// verb from the init object, path by concatenation: DELETE /api/orders/{} -> the inline handler +export async function cancelOrder(id: string) { return fetch('/api/orders/' + id, { method: 'DELETE' }); } + +// an instance with a base URL: GET /api/invoices/{} -> getInvoice +const billing = axios.create({ baseURL: 'http://billing:9000/api/invoices' }); +export async function invoice(id: string) { return billing.get(`/${id}`); } + +// served by nothing here: remote_unserved +export async function audit() { return axios.get('/api/audit'); } + +// a destination that cannot be read: remote_undetermined +export async function anywhere(url: string) { return fetch(url); } + +// CONTROL: a Map keyed by a path-shaped string is not a send +const cache = new Map(); +export function cached(): string | undefined { return cache.get('/api/orders'); } diff --git a/graph/test/typescript/cases/78-cross-process-destinations/src/server/orders.ts b/graph/test/typescript/cases/78-cross-process-destinations/src/server/orders.ts new file mode 100644 index 000000000..9dead2f24 --- /dev/null +++ b/graph/test/typescript/cases/78-cross-process-destinations/src/server/orders.ts @@ -0,0 +1,26 @@ +// The server end, in the call form every router in this ecosystem shares: +// `app.(path, handler)`. The path is a literal, or a constant from another module. +import { ORDERS } from '../shared/paths'; + +declare const app: { + get(path: string, h: unknown): void; + post(path: string, h: unknown): void; +}; +declare const router: { + delete(path: string, h: unknown): void; +}; + +export function getOrder(): string { return 'one'; } +export function listOrders(): string { return 'all'; } +export function createOrder(): string { return 'created'; } +export function cancelWork(): string { return 'cancelled'; } +export function getInvoice(): string { return 'invoice'; } + +// literal path with a route parameter +app.get('/api/orders/:id', getOrder); +// the same constant under two verbs: a POST must reach createOrder and never listOrders +app.get(ORDERS, listOrders); +app.post(ORDERS, createOrder); +app.get('/api/invoices/:id', getInvoice); +// an inline handler +router.delete('/api/orders/:id', () => cancelWork()); diff --git a/graph/test/typescript/cases/78-cross-process-destinations/src/server/reports.controller.ts b/graph/test/typescript/cases/78-cross-process-destinations/src/server/reports.controller.ts new file mode 100644 index 000000000..d5d694c18 --- /dev/null +++ b/graph/test/typescript/cases/78-cross-process-destinations/src/server/reports.controller.ts @@ -0,0 +1,16 @@ +// The server end in the decorator form: the class route is a prefix of every method route. +function Controller(_path: string) { return (_t: Function): void => {}; } +function Get(_path: string) { return (_t: object, _k: string, _d: PropertyDescriptor): void => {}; } +function Post(_path: string) { return (_t: object, _k: string, _d: PropertyDescriptor): void => {}; } + +@Controller('api/reports') +export class ReportsController { + @Get(':day') + daily(): string { return 'daily'; } + + @Post('rebuild') + rebuild(): string { return 'rebuilt'; } + + // no route decorator: served by nothing + helper(): string { return 'helper'; } +} diff --git a/graph/test/typescript/cases/78-cross-process-destinations/src/shared/config.ts b/graph/test/typescript/cases/78-cross-process-destinations/src/shared/config.ts new file mode 100644 index 000000000..573fb5dab --- /dev/null +++ b/graph/test/typescript/cases/78-cross-process-destinations/src/shared/config.ts @@ -0,0 +1,6 @@ +// A configured base address: the client reads it off a config object, never as a literal +// at the send. The scheme and host are dropped when the two ends are compared. +export const config = { + reportsUrl: 'http://reports:8080/api/reports', + timeoutMs: 3000, +}; diff --git a/graph/test/typescript/cases/78-cross-process-destinations/src/shared/paths.ts b/graph/test/typescript/cases/78-cross-process-destinations/src/shared/paths.ts new file mode 100644 index 000000000..658f4229b --- /dev/null +++ b/graph/test/typescript/cases/78-cross-process-destinations/src/shared/paths.ts @@ -0,0 +1,2 @@ +// A route spelled once and imported by both ends: the "constant declared elsewhere" shape. +export const ORDERS = '/api/orders'; diff --git a/graph/test/typescript/cases/78-cross-process-destinations/src/tsconfig.json b/graph/test/typescript/cases/78-cross-process-destinations/src/tsconfig.json new file mode 100644 index 000000000..b09665abf --- /dev/null +++ b/graph/test/typescript/cases/78-cross-process-destinations/src/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true, + "experimentalDecorators": true + } +} diff --git a/graph/test/typescript/expected/78-cross-process-destinations.edges b/graph/test/typescript/expected/78-cross-process-destinations.edges new file mode 100644 index 000000000..3f6b75159 --- /dev/null +++ b/graph/test/typescript/expected/78-cross-process-destinations.edges @@ -0,0 +1,25 @@ +ambiguous_unknown CONSTRUCTOR_CALL client/client#() @L33 -> - +ambiguous_unknown FUNCTION_CALL client/client#anywhere(string) @L30 -> - +ambiguous_unknown FUNCTION_CALL client/client#cancelOrder(string) @L20 -> - +ambiguous_unknown FUNCTION_CALL client/client#fetchOrder(string) @L8 -> - +ambiguous_unknown FUNCTION_CALL client/client#listAll() @L14 -> - +ambiguous_unknown METHOD_CALL client/client#cached() @L34 -> - +callback_registered METHOD_CALL server/orders#() @L20 -> server/orders#getOrder() +callback_registered METHOD_CALL server/orders#() @L22 -> server/orders#listOrders() +callback_registered METHOD_CALL server/orders#() @L23 -> server/orders#createOrder() +callback_registered METHOD_CALL server/orders#() @L24 -> server/orders#getInvoice() +callback_registered METHOD_CALL server/orders#() @L26 -> server/orders#() +known_edge DECORATOR_CALL ReportsController#daily() @L8 -> server/reports.controller#Get(string) +known_edge DECORATOR_CALL ReportsController#rebuild() @L11 -> server/reports.controller#Post(string) +known_edge DECORATOR_CALL server/reports.controller#() @L6 -> server/reports.controller#Controller(string) +known_edge FUNCTION_CALL server/orders#() @L26 -> server/orders#cancelWork() +known_edge METHOD_CALL client/client#() @L23 -> HttpStatic#create({ baseURL?: string }) +known_edge METHOD_CALL client/client#audit() @L27 -> HttpInstance#get(string,unknown) +known_edge METHOD_CALL client/client#dailyReport(string) @L17 -> HttpInstance#get(string,unknown) +known_edge METHOD_CALL client/client#invoice(string) @L24 -> HttpInstance#get(string,unknown) +known_edge METHOD_CALL client/client#placeOrder(unknown) @L11 -> HttpInstance#post(string,unknown,unknown) +known_edge METHOD_CALL server/orders#() @L20 -> server/orders#get(string,unknown) +known_edge METHOD_CALL server/orders#() @L22 -> server/orders#get(string,unknown) +known_edge METHOD_CALL server/orders#() @L23 -> server/orders#post(string,unknown) +known_edge METHOD_CALL server/orders#() @L24 -> server/orders#get(string,unknown) +known_edge METHOD_CALL server/orders#() @L26 -> server/orders#delete(string,unknown) diff --git a/graph/test/typescript/expected/78-cross-process-destinations.entries b/graph/test/typescript/expected/78-cross-process-destinations.entries new file mode 100644 index 000000000..21d5f6de3 --- /dev/null +++ b/graph/test/typescript/expected/78-cross-process-destinations.entries @@ -0,0 +1,40 @@ +── entry_point (26) ── + bean_ctor server/reports.controller#ReportsController. reports.controller.ts:6 + exported_from_entry_module client/client#anywhere client.ts:30 + exported_from_entry_module client/client#audit client.ts:27 + exported_from_entry_module client/client#cached client.ts:34 + exported_from_entry_module client/client#cancelOrder client.ts:20 + exported_from_entry_module client/client#dailyReport client.ts:17 + exported_from_entry_module client/client#fetchOrder client.ts:8 + exported_from_entry_module client/client#invoice client.ts:24 + exported_from_entry_module client/client#listAll client.ts:14 + exported_from_entry_module client/client#placeOrder client.ts:11 + exported_from_entry_module server/orders#cancelWork orders.ts:16 + exported_from_entry_module server/orders#createOrder orders.ts:15 + exported_from_entry_module server/orders#getInvoice orders.ts:17 + exported_from_entry_module server/orders#getOrder orders.ts:13 + exported_from_entry_module server/orders#listOrders orders.ts:14 + http server/orders# orders.ts:26 + http server/orders#createOrder orders.ts:15 + http server/orders#getInvoice orders.ts:17 + http server/orders#getOrder orders.ts:13 + http server/orders#listOrders orders.ts:14 + http server/reports.controller#ReportsController.daily reports.controller.ts:8 + http server/reports.controller#ReportsController.rebuild reports.controller.ts:11 + unimported_module client/axios# axios.d.ts:1 + unimported_module client/client# client.ts:1 + unimported_module server/orders# orders.ts:1 + unimported_module server/reports.controller# reports.controller.ts:1 +── remote_edge (6) ── + client/client#cancelOrder server/orders# http /api/orders/{} exact + client/client#dailyReport server/reports.controller#ReportsController.daily http /api/reports/{} exact + client/client#fetchOrder server/orders#getOrder http /api/orders/{} exact + client/client#invoice server/orders#getInvoice http /api/invoices/{} exact + client/client#listAll server/orders#listOrders http /api/orders exact + client/client#placeOrder server/orders#createOrder http /api/orders exact +── remote_unserved (1) ── + client/client#audit http /api/audit +── remote_unsent (1) ── + server/reports.controller#ReportsController.rebuild http /api/reports/rebuild +── remote_undetermined (1) ── + client/client#anywhere http unresolved_destination diff --git a/graph/test/typescript/expected/78-cross-process-destinations.fields b/graph/test/typescript/expected/78-cross-process-destinations.fields new file mode 100644 index 000000000..4fea0459e --- /dev/null +++ b/graph/test/typescript/expected/78-cross-process-destinations.fields @@ -0,0 +1 @@ +ambiguous_unknown read client/client#dailyReport(string) -> - diff --git a/graph/test/typescript/expected/78-cross-process-destinations.fields-oracle b/graph/test/typescript/expected/78-cross-process-destinations.fields-oracle new file mode 100644 index 000000000..bd6298b8a --- /dev/null +++ b/graph/test/typescript/expected/78-cross-process-destinations.fields-oracle @@ -0,0 +1,8 @@ +78-cross-process-destinations [fields] + precision 0.0000 (0 correct, 0 wrong) + recall 0.0000 (0 of 1 the compiler resolved) + sites 1 resolved 0 (0.0%) + tiers ambiguous_unknown=1 + access read=1 + not scored: 1 rows whose target is not a client declaration + MISSING client/client#dailyReport(string) READ shared/config#reportsUrl diff --git a/graph/test/typescript/expected/78-cross-process-destinations.known-missing b/graph/test/typescript/expected/78-cross-process-destinations.known-missing new file mode 100644 index 000000000..3b1118ba2 --- /dev/null +++ b/graph/test/typescript/expected/78-cross-process-destinations.known-missing @@ -0,0 +1,11 @@ +# Accepted gaps for 78-cross-process-destinations — each line is an edge the TypeScript +# compiler resolves and this engine does not. A NEW missing edge fails the suite; +# a line here that STARTS working also fails, so the debt cannot rot. +# +# All three are #233, the gap case 68 records: the function a decorator factory +# returns is applied to the decorated declaration, and that hop has no call site. +# The routes this case tests are read from the decorator's NAME and ARGUMENT through +# annotation_on / annotation_arg, never from the application edge. +ReportsController#daily() -> server/reports.controller#(object,string,PropertyDescriptor) +ReportsController#rebuild() -> server/reports.controller#(object,string,PropertyDescriptor) +server/reports.controller#() -> server/reports.controller#(Function) diff --git a/graph/test/typescript/expected/78-cross-process-destinations.oracle b/graph/test/typescript/expected/78-cross-process-destinations.oracle new file mode 100644 index 000000000..661c0433f --- /dev/null +++ b/graph/test/typescript/expected/78-cross-process-destinations.oracle @@ -0,0 +1,4 @@ +oracle=15 engine=12 agree=12 missing=3 (known 3, NEW 0) extra=0 + known ReportsController#daily() -> server/reports.controller#(object,string,PropertyDescriptor) + known ReportsController#rebuild() -> server/reports.controller#(object,string,PropertyDescriptor) + known server/reports.controller#() -> server/reports.controller#(Function) diff --git a/graph/test/typescript/expected/78-cross-process-destinations.type-use b/graph/test/typescript/expected/78-cross-process-destinations.type-use new file mode 100644 index 000000000..99f271282 --- /dev/null +++ b/graph/test/typescript/expected/78-cross-process-destinations.type-use @@ -0,0 +1,7 @@ +ambiguous_unknown DECORATOR_TYPE 0 ReportsController [DECORATOR] -> - +ambiguous_unknown METHOD_PARAM 0 server/reports.controller [METHOD_PARAM] -> - +ambiguous_unknown METHOD_RETURN 0 HttpInstance [METHOD] -> - +ambiguous_unknown OBJECT_CREATION_TYPE 0 client/client [EXPRESSION] -> - +known_edge METHOD_RETURN 0 HttpStatic [METHOD] -> HttpInstance +known_edge SUPER_TYPE 0 HttpStatic [HERITAGE] -> HttpInstance +known_edge VARIABLE_TYPE 0 client/axios [VARIABLE] -> HttpStatic diff --git a/graph/test/typescript/expected/78-cross-process-destinations.types-oracle b/graph/test/typescript/expected/78-cross-process-destinations.types-oracle new file mode 100644 index 000000000..2bfbe9f8a --- /dev/null +++ b/graph/test/typescript/expected/78-cross-process-destinations.types-oracle @@ -0,0 +1,9 @@ +78-cross-process-destinations [types] + precision 0.5000 (1 correct, 1 wrong) + recall 0.5000 (1 of 2 the compiler resolved) + sites 12 resolved 3 (25.0%) + tiers ambiguous_unknown=9 known_edge=3 + contexts DECORATOR_TYPE=3 METHOD_PARAM=3 METHOD_RETURN=3 OBJECT_CREATION_TYPE=1 SUPER_TYPE=1 VARIABLE_TYPE=1 + not scored: 9 rows whose target is not a client declaration + WRONG client/axios USES HttpStatic + MISSING client/axios.d USES HttpStatic diff --git a/graph/test/typescript/tools/entry_report.py b/graph/test/typescript/tools/entry_report.py index a9308c2e7..fb0644d6d 100755 --- a/graph/test/typescript/tools/entry_report.py +++ b/graph/test/typescript/tools/entry_report.py @@ -96,6 +96,22 @@ def main(): print(f"── di_edge ({len(di)}) ──") for status, slot, bean in sorted(di): print(f" {status:11s} {slot} <- {bean}") + # The cross-process edge (framework-behavior/destinations.dl) is not a call either, + # so .edges cannot see it. Printed only when present: every other case's golden + # stays as it was. + for rel, cols in (('remote-edge', 5), ('remote-unserved', 3), ('remote-unsent', 3), + ('remote-undetermined', 3)): + p = os.path.join(out, rel + '.csv') + got = set() + if os.path.exists(p): + with open(p, newline='') as fh: + for r in csv.reader(fh, delimiter='\t'): + if len(r) >= cols: + got.add(tuple(name.get(x, x).split(' ')[0] for x in r[:cols])) + if got: + print(f"── {rel.replace('-', '_')} ({len(got)}) ──") + for row in sorted(got): + print(' ' + ' '.join(row)) return 0 diff --git a/graph/typescript/engine/config-resolution/knobs.dl b/graph/typescript/engine/config-resolution/knobs.dl index 944fa0171..3e093b7a6 100644 --- a/graph/typescript/engine/config-resolution/knobs.dl +++ b/graph/typescript/engine/config-resolution/knobs.dl @@ -131,3 +131,74 @@ ts_framework_lifecycle_method("onModuleInit"). ts_framework_lifecycle_me ts_framework_lifecycle_method("onApplicationBootstrap"). ts_framework_lifecycle_method("onApplicationShutdown"). ts_framework_lifecycle_method("beforeApplicationShutdown"). + +// ============================================================================ +// CROSS-PROCESS DESTINATIONS (framework-behavior/destinations.dl) +// +// The names the remote_edge rules key on. As in Java and C#, no rule there names a +// framework: adding an HTTP client or a route decorator is a fact added here. +// ============================================================================ + +// ── the vocabulary of the edge itself ─────────────────────────────────────── +.decl ts_remote_transport_http(c0:symbol) +ts_remote_transport_http("http"). +.decl ts_remote_confidence_exact(c0:symbol) +ts_remote_confidence_exact("exact"). +// a placeholder on one end stood in for a literal on the other +.decl ts_remote_confidence_route_shape(c0:symbol) +ts_remote_confidence_route_shape("route_shape"). +// the verb that matches every other one: `app.all`, `@All`, a request whose method +// is computed +.decl ts_http_verb_any(c0:symbol) +ts_http_verb_any("ANY"). + +// ── ts_http_verb_name(Written, Verb) — a verb as a program spells it ───────── +// A router method is lower case, a `method:` option is either. `all` is any verb. +// `use` and `route` are deliberately absent: `use` mounts middleware or a router +// under a PREFIX, which is not a route a client can name. +.decl ts_http_verb_name(c0:symbol, c1:symbol) +ts_http_verb_name("get", "GET"). ts_http_verb_name("GET", "GET"). +ts_http_verb_name("post", "POST"). ts_http_verb_name("POST", "POST"). +ts_http_verb_name("put", "PUT"). ts_http_verb_name("PUT", "PUT"). +ts_http_verb_name("delete", "DELETE"). ts_http_verb_name("DELETE", "DELETE"). +ts_http_verb_name("patch", "PATCH"). ts_http_verb_name("PATCH", "PATCH"). +ts_http_verb_name("head", "HEAD"). ts_http_verb_name("HEAD", "HEAD"). +ts_http_verb_name("options", "OPTIONS"). ts_http_verb_name("OPTIONS", "OPTIONS"). +ts_http_verb_name("all", "ANY"). + +// ── ts_route_decorator_verb(Decorator, Verb) — a Nest method route ────────── +.decl ts_route_decorator_verb(c0:symbol, c1:symbol) +ts_route_decorator_verb("Get", "GET"). ts_route_decorator_verb("Post", "POST"). +ts_route_decorator_verb("Put", "PUT"). ts_route_decorator_verb("Delete", "DELETE"). +ts_route_decorator_verb("Patch", "PATCH"). ts_route_decorator_verb("Head", "HEAD"). +ts_route_decorator_verb("Options", "OPTIONS"). ts_route_decorator_verb("All", "ANY"). + +// ── ts_route_prefix_decorator(Name) — a class decorator whose argument prefixes +// every route of its methods (`@Controller('orders')`) +.decl ts_route_prefix_decorator(c0:symbol) +ts_route_prefix_decorator("Controller"). + +// ── the client end ────────────────────────────────────────────────────────── +// ts_http_fetch_function(Name): a function that sends its first argument as a URL, +// with the verb in the `method` of its second. The platform's `fetch` is an ambient +// global; the packages that polyfill it export the same function. +.decl ts_http_fetch_function(c0:symbol) +ts_http_fetch_function("fetch"). +.decl ts_http_fetch_module(c0:symbol) +ts_http_fetch_module("node-fetch"). ts_http_fetch_module("cross-fetch"). +ts_http_fetch_module("undici"). ts_http_fetch_module("isomorphic-fetch"). +// ts_http_init_method_key(Key): the option of a request init that names the verb +.decl ts_http_init_method_key(c0:symbol) +ts_http_init_method_key("method"). + +// ts_http_client_module(Specifier): a package whose default or named export is a +// client called as `.(url, …)`. Matched on the IMPORT, never on the +// receiver's spelling: a variable that happens to be called `axios` is not one. +.decl ts_http_client_module(c0:symbol) +ts_http_client_module("axios"). +// ts_http_client_factory(Name): `axios.create({ baseURL })` returns a client whose +// every URL is relative to the base +.decl ts_http_client_factory(c0:symbol) +ts_http_client_factory("create"). +.decl ts_http_base_url_key(c0:symbol) +ts_http_base_url_key("baseURL"). diff --git a/graph/typescript/engine/framework-behavior/destinations.dl b/graph/typescript/engine/framework-behavior/destinations.dl new file mode 100644 index 000000000..7370082c6 --- /dev/null +++ b/graph/typescript/engine/framework-behavior/destinations.dl @@ -0,0 +1,415 @@ +// ============================================================================ +// FRAMEWORK-BEHAVIOR · DESTINATIONS (the cross-process edge) +// +// The TypeScript counterpart of java/, python/ and csharp/engine/framework-behavior/ +// destinations.dl, under the same contract: +// - a rule here models a LIBRARY's run-time behaviour, and every framework name it +// keys on lives in config-resolution/knobs.dl; +// - a rule here may only ADD relations. It never changes call_chain_edge or anything +// the resolution phases own: a framework model is weaker knowledge than a resolved +// call; +// - no compiler can adjudicate it. Its oracle is a running system. +// +// A client and the server that answers it are in different processes. Neither calls +// the other, so every rule in call-edge-generation is silent across that boundary and +// an impact question stops at the send. What ties the two ends together is a ROUTE both +// ends spell. The result is `remote_edge`, deliberately NOT a call_chain_edge: +// +// remote_edge(FromMethod, ToMethod, Transport, Destination, Confidence) +// +// and the halves that could not be joined, reported rather than dropped: +// remote_unserved(Method, Transport, Destination) a send nothing here serves +// remote_unsent(Method, Transport, Destination) a handler nothing here sends to +// remote_undetermined(Method, Transport, Reason) a send whose destination cannot be read +// +// COVERED: HTTP. The server as a router call (`app.get('/x', h)`, `router.post(...)`) +// or a decorated controller (`@Controller('x')` over `@Get(':id')`); the client as +// `fetch` or an HTTP client package called `.(url)`, directly or through +// an instance its factory made with a base URL. A route or a URL is read from a string +// literal, a template, a `+` concatenation, a constant (in this module or imported), +// and a property of a const object literal (a config module). +// +// NOT COVERED YET, stated rather than left to be discovered: +// - a router mounted under a prefix (`app.use('/api', router)`): the routes of the +// router are compared without it; +// - an object-form registration (`fastify.route({ method, url, handler })`); +// - a URL built by a wrapper the client calls with the path (the C# rules bind one +// hop of that; here the wrapper's own send is reported undetermined); +// - messaging (a topic, a queue) and gRPC. +// ============================================================================ + +// remote_edge, remote_unserved, remote_unsent and remote_undetermined are declared in +// souffle/decls_all.dl: they are exported, and the bundle reads an export's arity there. +.decl remote_edge_via(c0:symbol, c1:symbol, c2:symbol, c3:symbol, c4:symbol, c5:symbol) +.decl remote_send_at(c0:symbol, c1:symbol, c2:symbol, c3:symbol) +.decl remote_serves(c0:symbol, c1:symbol, c2:symbol) + +// ───────────────────────────────────────────────────────────────────────────── +// 1. THE STRING AN EXPRESSION HOLDS +// ───────────────────────────────────────────────────────────────────────────── +// Read on demand: only a route's path argument and a send's URL argument, and what +// those are built from, are ever evaluated. Anything computed at run time (a +// parameter, a call's result) becomes the placeholder `{}`, which matches one segment +// of anything on the other end. + +// the specifier an import names (`axios`, `../shared/paths`) +.decl rd_import_path(c0:symbol, c1:symbol) +rd_import_path(h, p) :- ts_import(_, p, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, h). + +// the const an identifier denotes, in this module or through an import +.decl rd_denotes_var(c0:symbol, c1:symbol) +rd_denotes_var(e, v) :- expr_referenced("client", "VARIABLE", v, e). +rd_denotes_var(e, v) :- expr_referenced("client", "IMPORT_BINDING", ih, e), + import_binds(ih, "client", "VARIABLE", v). + +// a property of an object literal: the key and the value share a position +.decl rd_obj_prop(c0:symbol, c1:symbol, c2:symbol) +rd_obj_prop(o, key, val) :- expr_kind("client", "OBJECT_LITERAL", _, o), + expr_child("client", o, "OBJECT_PROPERTY_KEY", pos, k), expr_literal_value("client", key, k), + expr_child("client", o, "OBJECT_PROPERTY_VALUE", pos, val). + +.decl rd_demand(c0:symbol) +.decl rd_prop_value(c0:symbol, c1:symbol) +rd_demand(c) :- rd_demand(e), expr_kind("client", "BINARY_EXPRESSION", _, e), expr_operator("client", "+", e), + expr_child("client", e, "LEFT_OPERAND", _, c). +rd_demand(c) :- rd_demand(e), expr_kind("client", "BINARY_EXPRESSION", _, e), expr_operator("client", "+", e), + expr_child("client", e, "RIGHT_OPERAND", _, c). +rd_demand(c) :- rd_demand(e), expr_literal_type("client", "TEMPLATE", e), + expr_child("client", e, "TEMPLATE_SPAN", _, c). +rd_demand(i) :- rd_demand(e), rd_denotes_var(e, v), var_initializer("client", _, i, v), i != "". +rd_demand(val) :- rd_prop_value(_, val). +rd_demand(c) :- rd_demand(e), expr_kind("client", k, _, e), expr_kind_is_transparent(k), + expr_child("client", e, _, _, c). + +// `config.reportsUrl`, where `config` is a const initialised with an object literal +rd_prop_value(e, val) :- rd_demand(e), expr_kind("client", "PROPERTY_ACCESS", _, e), + expr_child("client", e, "RECEIVER", _, r), expr_child("client", e, "PROPERTY_NAME", _, pn), + expr_literal_value("client", key, pn), + rd_denotes_var(r, v), var_initializer("client", _, o, v), rd_obj_prop(o, key, val). + +.decl rd_is_str(c0:symbol) +.decl rd_is_tmpl(c0:symbol) +.decl rd_is_plus(c0:symbol) +.decl rd_is_ref(c0:symbol) +.decl rd_is_prop(c0:symbol) +.decl rd_is_transparent(c0:symbol) +rd_is_str(e) :- rd_demand(e), expr_kind("client", "LITERAL", _, e), expr_literal_type("client", "STRING", e). +rd_is_tmpl(e) :- rd_demand(e), expr_literal_type("client", "TEMPLATE", e). +rd_is_plus(e) :- rd_demand(e), expr_kind("client", "BINARY_EXPRESSION", _, e), expr_operator("client", "+", e). +rd_is_ref(e) :- rd_demand(e), rd_denotes_var(e, v), var_initializer("client", _, i, v), i != "". +rd_is_prop(e) :- rd_prop_value(e, _). +rd_is_transparent(e) :- rd_demand(e), expr_kind("client", k, _, e), expr_kind_is_transparent(k), + expr_child("client", e, _, _, _). + +.decl rd_val(c0:symbol, c1:symbol) +// literalValue is the text between the quotes; an empty string has no row at all +rd_val(e, v) :- rd_is_str(e), expr_literal_value("client", v, e). +rd_val(e, "") :- rd_is_str(e), !expr_literal_value("client", _, e). +rd_val(e, cat(l, r)) :- rd_is_plus(e), + expr_child("client", e, "LEFT_OPERAND", _, lc), expr_child("client", e, "RIGHT_OPERAND", _, rc), + rd_val(lc, l), rd_val(rc, r), strlen(l) + strlen(r) < 400. +rd_val(e, v) :- rd_demand(e), rd_denotes_var(e, x), var_initializer("client", _, i, x), i != "", rd_val(i, v). +rd_val(e, v) :- rd_prop_value(e, val), rd_val(val, v). +rd_val(e, v) :- rd_is_transparent(e), expr_child("client", e, _, _, c), rd_val(c, v). +rd_val(e, "{}") :- rd_demand(e), !rd_is_str(e), !rd_is_tmpl(e), !rd_is_plus(e), !rd_is_ref(e), + !rd_is_prop(e), !rd_is_transparent(e). + +// ── a template: its raw text, split at each `${…}` ────────────────────────── +// The parser keeps the template as written, backticks included, and gives each hole +// its own TEMPLATE_SPAN child in order. So hole k of the text is span k, and the value +// is the text between the holes with each span's value put back. +.decl rd_tmpl_raw(c0:symbol, c1:symbol) +rd_tmpl_raw(e, raw) :- rd_is_tmpl(e), expr_literal_value("client", raw, e), strlen(raw) >= 2. +.decl rd_raw(c0:symbol) +rd_raw(raw) :- rd_tmpl_raw(_, raw). +.decl rd_open(c0:symbol, c1:number) +rd_open(raw, i) :- rd_raw(raw), i = range(0, strlen(raw) - 1), substr(raw, i, 2) = "${". +.decl rd_brace(c0:symbol, c1:number) +rd_brace(raw, i) :- rd_raw(raw), i = range(0, strlen(raw)), substr(raw, i, 1) = "}". +.decl rd_hole(c0:symbol, c1:number, c2:number, c3:number) +rd_hole(raw, k, a, b) :- rd_open(raw, a), k = count : { rd_open(raw, j), j < a }, + b = min x : { rd_brace(raw, x), x > a }. +.decl rd_holes(c0:symbol, c1:number) +rd_holes(raw, n) :- rd_raw(raw), n = count : { rd_open(raw, _) }. +// piece k is the text before hole k, and piece n (n holes) the text after the last one +.decl rd_piece(c0:symbol, c1:number, c2:symbol) +rd_piece(raw, 0, t) :- rd_hole(raw, 0, a, _), t = substr(raw, 1, a - 1). +rd_piece(raw, k, t) :- rd_hole(raw, k, a, _), k > 0, rd_hole(raw, k - 1, _, b), t = substr(raw, b + 1, a - b - 1). +rd_piece(raw, n, t) :- rd_holes(raw, n), n > 0, rd_hole(raw, n - 1, _, b), t = substr(raw, b + 1, strlen(raw) - b - 2). +rd_piece(raw, 0, t) :- rd_holes(raw, 0), t = substr(raw, 1, strlen(raw) - 2). +.decl rd_tstep(c0:symbol, c1:number, c2:symbol) +rd_tstep(e, 0, p) :- rd_tmpl_raw(e, raw), rd_piece(raw, 0, p). +rd_tstep(e, k + 1, s) :- rd_tstep(e, k, acc), rd_tmpl_raw(e, raw), rd_holes(raw, n), k < n, + expr_child("client", e, "TEMPLATE_SPAN", pos, c), to_number(pos) = k, rd_val(c, sv), + rd_piece(raw, k + 1, p), strlen(acc) + strlen(sv) < 400, s = cat(acc, cat(sv, p)). +rd_val(e, v) :- rd_tmpl_raw(e, raw), rd_holes(raw, n), rd_tstep(e, n, v). + +// ───────────────────────────────────────────────────────────────────────────── +// 2. THE SERVER: a route and the handler it runs +// ───────────────────────────────────────────────────────────────────────────── +// (method, path as written, verb) +.decl rd_route_raw(c0:symbol, c1:symbol, c2:symbol) + +// ── a router call: `app.get(path, …handlers)` ─────────────────────────────── +// The verb name is never matched alone (config-resolution/knobs.dl says why): the +// first argument must read as a path, and a later one must be a function. The call +// is not a route when its receiver is an HTTP client: `axios.get('/x', config)`. +.decl rd_route_call(c0:symbol, c1:symbol) +rd_route_call(ce, verb) :- call_site("client", "METHOD_CALL", cn, _, r, ce, _), ts_http_verb_name(cn, verb), + !rd_client_root(r). +rd_demand(a) :- rd_route_call(ce, _), expr_child("client", ce, "ARGUMENT", "0", a). +// Express runs every function argument in turn, the route's own handler last +.decl rd_route_fn_arg(c0:symbol, c1:number, c2:symbol) +rd_route_fn_arg(ce, p, m) :- rd_route_call(ce, _), callback_arg(ce, pos, m), p = to_number(pos), p > 0. +rd_route_fn_arg(ce, p, m) :- rd_route_call(ce, _), cfg_named_fn_arg(ce, pos, m), p = to_number(pos), p > 0. +.decl rd_route_last(c0:symbol, c1:number) +rd_route_last(ce, p) :- rd_route_fn_arg(ce, _, _), p = max q : { rd_route_fn_arg(ce, q, _) }. +rd_route_raw(m, p, verb) :- rd_route_call(ce, verb), rd_route_last(ce, k), rd_route_fn_arg(ce, k, m), + expr_child("client", ce, "ARGUMENT", "0", a), rd_val(a, p), strlen(p) >= 1, substr(p, 0, 1) = "/". + +// A handler registered under a path read from a constant is as much a root as one +// under a literal: config-resolution/entry-points.dl reads the literal only. +entry_point(m, "http") :- rd_route_call(ce, _), rd_route_last(ce, k), rd_route_fn_arg(ce, k, m), + expr_child("client", ce, "ARGUMENT", "0", a), rd_val(a, p), strlen(p) >= 1, substr(p, 0, 1) = "/". + +// ── a decorated controller: `@Controller('orders')` over `@Get(':id')` ───── +// A decorator argument keeps its quotes. +.decl rd_unquote(c0:symbol, c1:symbol) +rd_unquote(raw, v) :- annotation_arg("client", _, raw, "STRING", _, _, _, _), strlen(raw) >= 2, + q = substr(raw, 0, 1), (q = "'" ; q = "\"" ; q = "`"), v = substr(raw, 1, strlen(raw) - 2). +.decl rd_dec_path(c0:symbol, c1:symbol) +rd_dec_path(d, v) :- annotation_arg("client", _, raw, "STRING", "0", _, d, _), rd_unquote(raw, v). +.decl rd_has_dec_path(c0:symbol) +rd_has_dec_path(d) :- rd_dec_path(d, _). +.decl rd_ctl_prefix(c0:symbol, c1:symbol) +rd_ctl_prefix(t, v) :- annotation_on("client", n, _, "CLASS_DECLARATION", t, d), ts_route_prefix_decorator(n), + rd_dec_path(d, v). +rd_ctl_prefix(t, "") :- annotation_on("client", n, _, "CLASS_DECLARATION", t, d), ts_route_prefix_decorator(n), + !rd_has_dec_path(d). +.decl rd_ctl_action(c0:symbol, c1:symbol, c2:symbol) +rd_ctl_action(m, verb, v) :- annotation_on("client", n, _, "METHOD_DECLARATION", m, d), ts_route_decorator_verb(n, verb), + rd_dec_path(d, v). +rd_ctl_action(m, verb, "") :- annotation_on("client", n, _, "METHOD_DECLARATION", m, d), ts_route_decorator_verb(n, verb), + !rd_has_dec_path(d). +// joined with a slash each side; the segment split below skips the empty ones +rd_route_raw(m, p, verb) :- rd_ctl_action(m, verb, v), method_owner("client", t, m), rd_ctl_prefix(t, pre), + p = cat("/", cat(pre, cat("/", v))). + +// ───────────────────────────────────────────────────────────────────────────── +// 3. THE CLIENT: a send and the URL it is given +// ───────────────────────────────────────────────────────────────────────────── +// (call expression, sending method, verb, URL as built) +.decl rd_send(c0:symbol, c1:symbol, c2:symbol, c3:symbol) +.decl rd_send_site(c0:symbol, c1:symbol) + +// ── fetch(url, { method }) ────────────────────────────────────────────────── +// The platform's global, or the same function imported from a package that +// provides it. A project's own function called `fetch` is neither. +.decl rd_fetch_call(c0:symbol) +rd_fetch_call(ce) :- call_site("client", "FUNCTION_CALL", cn, _, _, ce, _), ts_http_fetch_function(cn), + expr_child("client", ce, "METHOD_NAME", _, f), expr_referenced("client", "AMBIENT_GLOBAL", _, f). +rd_fetch_call(ce) :- call_site("client", "FUNCTION_CALL", _, _, _, ce, _), + expr_child("client", ce, "METHOD_NAME", _, f), expr_referenced("client", "IMPORT_BINDING", ih, f), + rd_import_path(ih, p), ts_http_fetch_module(p). +// the verb: the init's `method` when it is a literal, GET when there is no init or it +// names no method, and any verb when the method is computed or the init is not +// written in place +.decl rd_fetch_init(c0:symbol, c1:symbol) +rd_fetch_init(ce, o) :- rd_fetch_call(ce), expr_child("client", ce, "ARGUMENT", "1", o). +.decl rd_fetch_method_val(c0:symbol, c1:symbol) +rd_fetch_method_val(ce, val) :- rd_fetch_init(ce, o), rd_obj_prop(o, key, val), ts_http_init_method_key(key). +.decl rd_fetch_verb_lit(c0:symbol, c1:symbol) +rd_fetch_verb_lit(ce, verb) :- rd_fetch_method_val(ce, val), expr_literal_value("client", w, val), ts_http_verb_name(w, verb). +.decl rd_fetch_has_method(c0:symbol) +rd_fetch_has_method(ce) :- rd_fetch_method_val(ce, _). +.decl rd_fetch_has_verb(c0:symbol) +rd_fetch_has_verb(ce) :- rd_fetch_verb_lit(ce, _). +.decl rd_fetch_verb(c0:symbol, c1:symbol) +rd_fetch_verb(ce, v) :- rd_fetch_verb_lit(ce, v). +rd_fetch_verb(ce, v) :- rd_fetch_has_method(ce), !rd_fetch_has_verb(ce), ts_http_verb_any(v). +rd_fetch_verb(ce, "GET") :- rd_fetch_call(ce), !rd_fetch_init(ce, _). +rd_fetch_verb(ce, "GET") :- rd_fetch_init(ce, o), expr_kind("client", "OBJECT_LITERAL", _, o), !rd_fetch_has_method(ce). +rd_fetch_verb(ce, v) :- rd_fetch_init(ce, o), !expr_kind("client", "OBJECT_LITERAL", _, o), ts_http_verb_any(v). +rd_demand(a) :- rd_fetch_call(ce), expr_child("client", ce, "ARGUMENT", "0", a). +rd_send(ce, from, verb, u) :- rd_fetch_verb(ce, verb), expr_child("client", ce, "ARGUMENT", "0", a), rd_val(a, u), + expr_enclosing_method(ce, from). +rd_send_site(ce, from) :- rd_fetch_call(ce), expr_enclosing_method(ce, from). + +// ── .(url, …) ───────────────────────────────────────────────── +// The receiver is the package's import itself, or a const its factory made: +// `const api = axios.create({ baseURL })`, whose every URL is relative to the base. +.decl rd_client_import(c0:symbol) +rd_client_import(r) :- expr_referenced("client", "IMPORT_BINDING", ih, r), rd_import_path(ih, p), ts_http_client_module(p). +.decl rd_factory(c0:symbol) +rd_factory(i) :- call_site("client", "METHOD_CALL", f, _, fr, i, _), ts_http_client_factory(f), rd_client_import(fr). +// (receiver, the factory call that made it, or "" for the import) +.decl rd_client_recv(c0:symbol, c1:symbol) +rd_client_recv(r, "") :- rd_client_import(r). +rd_client_recv(r, i) :- rd_denotes_var(r, v), var_initializer("client", _, i, v), rd_factory(i). +.decl rd_client_root(c0:symbol) +rd_client_root(r) :- rd_client_recv(r, _). +.decl rd_base_val(c0:symbol) +rd_base_val(bv) :- rd_factory(i), expr_child("client", i, "ARGUMENT", "0", o), rd_obj_prop(o, key, bv), + ts_http_base_url_key(key). +rd_demand(bv) :- rd_base_val(bv). +.decl rd_has_base(c0:symbol) +rd_has_base(i) :- rd_factory(i), expr_child("client", i, "ARGUMENT", "0", o), rd_obj_prop(o, key, _), + ts_http_base_url_key(key). +.decl rd_base(c0:symbol, c1:symbol) +rd_base("", ""). +rd_base(i, b) :- rd_factory(i), expr_child("client", i, "ARGUMENT", "0", o), rd_obj_prop(o, key, bv), + ts_http_base_url_key(key), rd_val(bv, b). +rd_base(i, "") :- rd_factory(i), !rd_has_base(i). +.decl rd_client_call(c0:symbol, c1:symbol, c2:symbol) +rd_client_call(ce, verb, i) :- call_site("client", "METHOD_CALL", cn, _, r, ce, _), ts_http_verb_name(cn, verb), + rd_client_recv(r, i). +rd_demand(a) :- rd_client_call(ce, _, _), expr_child("client", ce, "ARGUMENT", "0", a). +rd_send(ce, from, verb, u) :- rd_client_call(ce, verb, i), rd_base(i, b), expr_child("client", ce, "ARGUMENT", "0", a), + rd_val(a, x), u = cat(b, x), expr_enclosing_method(ce, from). +rd_send_site(ce, from) :- rd_client_call(ce, _, _), expr_enclosing_method(ce, from). + +// ───────────────────────────────────────────────────────────────────────────── +// 4. ONE SHAPE FOR BOTH ENDS: a path, split into segments +// ───────────────────────────────────────────────────────────────────────────── +// Scheme and host are dropped, a client's query string and fragment are dropped, and +// a segment that is a parameter (`:id`, `{…}`, `*`) or holds a computed part becomes +// the placeholder `{}`. +.decl rd_path_raw(c0:symbol) +rd_path_raw(p) :- rd_route_raw(_, p, _). +rd_path_raw(p) :- rd_send(_, _, _, p). +.decl rd_client_path(c0:symbol) +rd_client_path(p) :- rd_send(_, _, _, p). + +.decl rd_scheme_at(c0:symbol, c1:number) +rd_scheme_at(s, i) :- rd_path_raw(s), strlen(s) >= 3, i = range(0, strlen(s) - 2), substr(s, i, 3) = "://". +.decl rd_scheme_first(c0:symbol, c1:number) +rd_scheme_first(s, i) :- rd_scheme_at(s, _), i = min j : rd_scheme_at(s, j). +.decl rd_after_scheme(c0:symbol, c1:symbol) +rd_after_scheme(s, r) :- rd_scheme_first(s, i), r = substr(s, i + 3, strlen(s) - i - 3). +.decl rd_slash_in(c0:symbol, c1:number) +rd_slash_in(r, i) :- rd_after_scheme(_, r), i = range(0, strlen(r)), substr(r, i, 1) = "/". +.decl rd_no_scheme(c0:symbol, c1:symbol) +rd_no_scheme(s, s) :- rd_path_raw(s), !rd_scheme_at(s, _). +rd_no_scheme(s, p) :- rd_after_scheme(s, r), m = min i : rd_slash_in(r, i), p = substr(r, m, strlen(r) - m). +rd_no_scheme(s, "/") :- rd_after_scheme(s, r), !rd_slash_in(r, _). +// a client's `?` and `#` end the path; a server's `?` marks an optional parameter +.decl rd_cut(c0:symbol, c1:number) +rd_cut(p, i) :- rd_no_scheme(s, p), rd_client_path(s), i = range(0, strlen(p)), (substr(p, i, 1) = "?" ; substr(p, i, 1) = "#"). +.decl rd_norm(c0:symbol, c1:symbol) +rd_norm(s, p) :- rd_no_scheme(s, p), !rd_cut(p, _). +rd_norm(s, q) :- rd_no_scheme(s, p), m = min i : rd_cut(p, i), q = substr(p, 0, m). + +// segments: the text after each slash up to the next one, empty ones skipped +.decl rd_pn(c0:symbol) +rd_pn(n) :- rd_norm(_, n). +.decl rd_pn_slash(c0:symbol, c1:number) +rd_pn_slash(n, -1) :- rd_pn(n), strlen(n) >= 1, substr(n, 0, 1) != "/". +rd_pn_slash(n, i) :- rd_pn(n), i = range(0, strlen(n)), substr(n, i, 1) = "/". +.decl rd_pn_next(c0:symbol, c1:number, c2:number) +rd_pn_next(n, i, j) :- rd_pn_slash(n, i), j = min k : { rd_pn_slash(n, k), k > i }. +.decl rd_pn_has_next(c0:symbol, c1:number) +rd_pn_has_next(n, i) :- rd_pn_next(n, i, _). +.decl rd_pn_seg_at(c0:symbol, c1:number, c2:symbol) +rd_pn_seg_at(n, i, t) :- rd_pn_next(n, i, j), t = substr(n, i + 1, j - i - 1). +rd_pn_seg_at(n, i, t) :- rd_pn_slash(n, i), !rd_pn_has_next(n, i), t = substr(n, i + 1, strlen(n) - i - 1). +.decl rd_pn_nonempty_at(c0:symbol, c1:number) +rd_pn_nonempty_at(n, i) :- rd_pn_seg_at(n, i, t), t != "". +.decl rd_pn_seg_raw(c0:symbol, c1:number, c2:symbol) +rd_pn_seg_raw(n, k, t) :- rd_pn_seg_at(n, i, t), t != "", k = count : { rd_pn_nonempty_at(n, x), x < i }. +.decl rd_seg_ph(c0:symbol) +rd_seg_ph(t) :- rd_pn_seg_raw(_, _, t), substr(t, 0, 1) = ":". +rd_seg_ph(t) :- rd_pn_seg_raw(_, _, t), contains("{", t). +rd_seg_ph("*") :- rd_pn_seg_raw(_, _, "*"). +.decl rd_pn_seg(c0:symbol, c1:number, c2:symbol) +rd_pn_seg(n, k, "{}") :- rd_pn_seg_raw(n, k, t), rd_seg_ph(t). +rd_pn_seg(n, k, t) :- rd_pn_seg_raw(n, k, t), !rd_seg_ph(t). +.decl rd_pn_count(c0:symbol, c1:number) +rd_pn_count(n, c) :- rd_pn(n), c = count : { rd_pn_seg(n, _, _) }. + +// A client's leading placeholder is a BASE ADDRESS (`${API}/orders`) and is dropped +// when a literal segment follows it. A server's is a route parameter and stays. +.decl rd_client_drops_base(c0:symbol) +rd_client_drops_base(s) :- rd_client_path(s), rd_norm(s, n), rd_pn_seg(n, 0, "{}"), rd_pn_count(n, c), c > 1, + rd_pn_seg(n, k, t), k >= 1, t != "{}". +.decl rd_client_shift(c0:symbol, c1:symbol, c2:number) +rd_client_shift(s, n, 1) :- rd_client_path(s), rd_norm(s, n), rd_client_drops_base(s). +rd_client_shift(s, n, 0) :- rd_client_path(s), rd_norm(s, n), !rd_client_drops_base(s). +.decl rd_cseg(c0:symbol, c1:number, c2:symbol) +rd_cseg(s, k - d, t) :- rd_client_shift(s, n, d), rd_pn_seg(n, k, t), k >= d. +.decl rd_ccount(c0:symbol, c1:number) +rd_ccount(s, c - d) :- rd_client_shift(s, n, d), rd_pn_count(n, c). +.decl rd_chas_literal(c0:symbol) +rd_chas_literal(s) :- rd_cseg(s, _, t), t != "{}". +.decl rd_server_path(c0:symbol) +rd_server_path(p) :- rd_route_raw(_, p, _). +.decl rd_sseg(c0:symbol, c1:number, c2:symbol) +rd_sseg(p, k, t) :- rd_server_path(p), rd_norm(p, n), rd_pn_seg(n, k, t). +.decl rd_scount(c0:symbol, c1:number) +rd_scount(p, c) :- rd_server_path(p), rd_norm(p, n), rd_pn_count(n, c). +// `/:id?` in Express: the last parameter may be left off by the caller +.decl rd_server_last_optional(c0:symbol) +rd_server_last_optional(p) :- rd_server_path(p), rd_norm(p, n), rd_pn_count(n, c), rd_pn_seg_raw(n, c - 1, t), + rd_seg_ph(t), strlen(t) >= 2, substr(t, strlen(t) - 1, 1) = "?". + +// the destination a path is reported under: its segments joined back, placeholders as `{}` +.decl rd_dest_step(c0:symbol, c1:number, c2:symbol) +rd_dest_step(s, 0, "") :- rd_client_path(s). +rd_dest_step(s, k + 1, cat(acc, cat("/", t))) :- rd_dest_step(s, k, acc), rd_client_path(s), rd_cseg(s, k, t). +rd_dest_step(p, 0, "") :- rd_server_path(p). +rd_dest_step(p, k + 1, cat(acc, cat("/", t))) :- rd_dest_step(p, k, acc), rd_server_path(p), rd_sseg(p, k, t). +.decl rd_dest(c0:symbol, c1:symbol) +rd_dest(s, d) :- rd_client_path(s), rd_ccount(s, c), c > 0, rd_dest_step(s, c, d). +rd_dest(p, d) :- rd_server_path(p), rd_scount(p, c), c > 0, rd_dest_step(p, c, d). +rd_dest(p, "/") :- rd_server_path(p), rd_scount(p, 0). + +// ── the join ──────────────────────────────────────────────────────────────── +.decl rd_route_pair(c0:symbol, c1:symbol) +rd_route_pair(s, p) :- rd_ccount(s, c), rd_scount(p, c), rd_chas_literal(s). +rd_route_pair(s, p) :- rd_ccount(s, c), rd_scount(p, c + 1), rd_server_last_optional(p), rd_chas_literal(s). +.decl rd_seg_conflict(c0:symbol, c1:symbol) +rd_seg_conflict(s, p) :- rd_route_pair(s, p), rd_cseg(s, k, a), rd_sseg(p, k, b), a != b, a != "{}", b != "{}". +.decl rd_seg_literal_eq(c0:symbol, c1:symbol) +rd_seg_literal_eq(s, p) :- rd_route_pair(s, p), rd_cseg(s, k, a), rd_sseg(p, k, a), a != "{}". +.decl rd_seg_differs(c0:symbol, c1:symbol) +rd_seg_differs(s, p) :- rd_route_pair(s, p), rd_cseg(s, k, a), rd_sseg(p, k, b), a != b. +.decl rd_route_match(c0:symbol, c1:symbol, c2:symbol) +rd_route_match(s, p, conf) :- rd_route_pair(s, p), !rd_seg_conflict(s, p), rd_seg_literal_eq(s, p), + !rd_seg_differs(s, p), ts_remote_confidence_exact(conf). +rd_route_match(s, p, conf) :- rd_route_pair(s, p), !rd_seg_conflict(s, p), rd_seg_literal_eq(s, p), + rd_seg_differs(s, p), ts_remote_confidence_route_shape(conf). + +.decl rd_verb_ok(c0:symbol, c1:symbol) +rd_verb_ok(a, a) :- rd_send(_, _, a, _). +rd_verb_ok(a, b) :- rd_send(_, _, a, _), rd_route_raw(_, _, b), ts_http_verb_any(a). +rd_verb_ok(a, b) :- rd_send(_, _, a, _), rd_route_raw(_, _, b), ts_http_verb_any(b). + +// ROUTE PRECEDENCE: of the verb-compatible routes a send fits, only those with the +// FEWEST differing segments are kept, so `/orders/{}` does not also land on +// `/orders/export` when `/orders/:id` is there. +.decl rd_seg_diff_n(c0:symbol, c1:symbol, c2:number) +rd_seg_diff_n(s, p, n) :- rd_route_match(s, p, _), n = count : { rd_cseg(s, k, a), rd_sseg(p, k, b), a != b }. +.decl rd_candidate(c0:symbol, c1:symbol, c2:symbol, c3:symbol, c4:symbol, c5:number) +rd_candidate(e, from, to, p, conf, n) :- rd_send(e, from, sv, s), rd_route_match(s, p, conf), + rd_route_raw(to, p, rv), rd_verb_ok(sv, rv), rd_seg_diff_n(s, p, n), from != to. +.decl rd_candidate_best(c0:symbol, c1:number) +rd_candidate_best(e, b) :- rd_candidate(e, _, _, _, _, _), b = min n : rd_candidate(e, _, _, _, _, n). + +remote_edge_via(e, from, to, tr, d, conf) :- ts_remote_transport_http(tr), + rd_candidate(e, from, to, p, conf, n), rd_candidate_best(e, n), rd_dest(p, d). +remote_serves(m, tr, d) :- ts_remote_transport_http(tr), rd_route_raw(m, p, _), rd_dest(p, d). +remote_send_at(e, from, tr, d) :- ts_remote_transport_http(tr), rd_send(e, from, _, s), rd_chas_literal(s), rd_dest(s, d). +.decl rd_send_has_literal(c0:symbol) +rd_send_has_literal(e) :- rd_send(e, _, _, s), rd_chas_literal(s). +// a URL with no literal segment at all, `fetch(url)`: nothing can be said about where +// it goes, and saying so beats silence +remote_undetermined(from, tr, "unresolved_destination") :- ts_remote_transport_http(tr), + rd_send_site(e, from), !rd_send_has_literal(e). + +// ───────────────────────────────────────────────────────────────────────────── +// 5. THE EDGE, AND THE HALVES IT CANNOT JOIN +// ───────────────────────────────────────────────────────────────────────────── +remote_edge(from, to, tr, d, conf) :- remote_edge_via(_, from, to, tr, d, conf). +// judged per SEND SITE: a site that linked is not also reported under another spelling +remote_unserved(m, tr, d) :- remote_send_at(e, m, tr, d), !remote_edge_via(e, _, _, _, _, _). +// a handler nothing here sends to: its client is in another repository +remote_unsent(m, tr, d) :- remote_serves(m, tr, d), !remote_edge(_, m, tr, d, _). diff --git a/graph/typescript/souffle/decls_all.dl b/graph/typescript/souffle/decls_all.dl index d1c62dd64..9102e4c5c 100644 --- a/graph/typescript/souffle/decls_all.dl +++ b/graph/typescript/souffle/decls_all.dl @@ -669,3 +669,7 @@ .decl program_module_count(c0:symbol,c1:number) .decl program_is_wide(c0:symbol) .decl global_decl_wide(c0:symbol,c1:symbol,c2:symbol,c3:symbol) +.decl remote_edge(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol) +.decl remote_undetermined(c0:symbol,c1:symbol,c2:symbol) +.decl remote_unsent(c0:symbol,c1:symbol,c2:symbol) +.decl remote_unserved(c0:symbol,c1:symbol,c2:symbol) diff --git a/graph/typescript/souffle/export_manifest.tsv b/graph/typescript/souffle/export_manifest.tsv index fb938b8fa..23b265dfb 100644 --- a/graph/typescript/souffle/export_manifest.tsv +++ b/graph/typescript/souffle/export_manifest.tsv @@ -50,3 +50,7 @@ type_use type-use.csv field_site_excluded field-site-excluded.csv inject_point config-inject-point.csv di_edge config-di-edge.csv +remote_edge remote-edge.csv +remote_unserved remote-unserved.csv +remote_unsent remote-unsent.csv +remote_undetermined remote-undetermined.csv diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context index a576fde05..6be6f0b29 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context @@ -519,6 +519,7 @@ def unseen(g, text, scopes=()): NONSOURCE_ASK = re.compile(r'\b(sql|quer(y|ies)|statements?|mapper|mappings?|xml|ya?ml|propert(y|ies)|config(uration|ured)?|' r'settings?|templates?|views?|html|json|migrations?|schema|scripts?|toml|ini|env|bindings?)\b', re.I) BINDINGS_SHOWN = 6 +REMOTE_SHOWN = 8 # cross-process hops listed beside "where the work is" (#1293) _NS = {} @@ -1174,6 +1175,35 @@ def main(argv): if b > a + SOURCE_LINES: print(f" {'':22} {'':5} | … +{b - a - SOURCE_LINES} more line(s)") if foot and budget > 0: print(foot + ", tests and benchmarks not listed") + # THE OTHER END OF A ROUTE IS NO CALL HOP AWAY, HOWEVER CLOSE IT IS (#1293). A client and the handler it reaches + # through an HTTP route, a topic or a gRPC service are joined by ext_remote_edge, which `impact` and `path` read and + # this did not, so the service on the other side never appeared. Its own tier, with the transport and destination, + # and never merged into the hops above: it crosses a process and may be asynchronous. + RESULT['remote'] = [] + if g.has('ext_remote_edge') and depth: + rows = [] + for r in g.q("SELECT c0, c1, c2, c3, c4 FROM ext_remote_edge"): + fr, to = r[0], r[1] + if not fr or not to or fr == to or fr not in g.sym or to not in g.sym: continue + d = min(depth.get(fr, 99), depth.get(to, 99)) + if d == 99: continue + near_is_sender = depth.get(fr, 99) <= depth.get(to, 99) + rows.append((d, g.disp(fr), g.disp(to), fr, to, r[2], r[3], r[4], near_is_sender)) + rows.sort(key=lambda x: (x[0], x[1], x[2], x[6])) + if scopes and not scope_offered: + rows = [x for x in rows if in_scope(g.sym[x[3]].get('file')) or in_scope(g.sym[x[4]].get('file'))] + if rows: + print(f"\nacross a process boundary ({len(rows)} remote hop(s) from what is above; not calls — joined by the " + "destination both ends name):") + for d, _a, _b, fr, to, tr, dest, conf, near_is_sender in rows[:REMOTE_SHOWN]: + far = to if near_is_sender else fr + print(f" [remote] {tr} {dest} ({conf}) {g.disp(fr)} → {g.disp(to)} " + f"{'handled in' if near_is_sender else 'sent from'} {g.loc(far)}") + if len(rows) > REMOTE_SHOWN: print(f" … +{len(rows) - REMOTE_SHOWN} more") + RESULT['remote'] = [{'from': g.disp(fr), 'from_at': g.loc(fr), 'to': g.disp(to), 'to_at': g.loc(to), + 'transport': tr, 'destination': dest, 'confidence': conf, 'hop': d} + for d, _a, _b, fr, to, tr, dest, conf, _n in rows] + # the text files bound to what was found (#1382): only when the question is about them (SQL, config, a template, a # file or directory the graph does not hold), because finding them walks the tree RESULT['text_bindings'] = [] diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index ebd7a6afc..47f57e4a5 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -1975,7 +1975,10 @@ def main(argv): 'ambient_terminal': 'registered', 'dynamic_terminal': 'registered', 'boundary_generated': 'sound', 'implicit_constructor': 'sound', # the candidate set was too large to enumerate, so what is here is a SAMPLE of it - 'fan_capped': 'by name'} + 'fan_capped': 'by name', + # a client across a process to the handler it reaches (#1293): joined by the destination both ends + # name, not by a call + 'remote': 'remote'} # 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 @@ -2013,7 +2016,10 @@ 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, 'one of a set': 3, 'dispatch': 4, 'registered': 5, - 'by key': 6, 'decorator by name': 7, 'protocol': 8, 'fixture': 9, 'by name': 10} + 'remote': 6, 'by key': 7, 'decorator by name': 8, 'protocol': 9, 'fixture': 10, 'by name': 11} + # `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 + # measured, like every rung below `one of a set`. # `registered` is inserted, and nothing above or below it is re-ordered: every rung the precisions were # measured for keeps the neighbours it had. It sits below `one of a set` because it is not a resolved # call, and above `by key` because the ENGINE recorded the hand-off, where a key join is this file's own @@ -2074,7 +2080,7 @@ def main(argv): # `decorator by name` is the rules' own hop from a decorated def to the decorator it names # (dl/impact.dl fw_edge), asserted exactly as `decorator` is; leaving it out printed "✗ not found" on # every answer whose test route passes through a project decorator - if (a, b) not in real and not any(x == b and t in ('dispatch', 'defines', 'by key', 'decorator', 'decorator by name') for x, t in parent.get(a, [])): bad += 1 + if (a, b) not in real and not any(x == b and t in ('dispatch', 'defines', 'by key', 'decorator', 'decorator by name', 'remote') for x, t in parent.get(a, [])): bad += 1 asserted = collections.defaultdict(set) # the callee each direct row claims an edge to for c, callee in {(x[0], x[1]) for x in res['direct_edge']}: asserted[c].add(callee) for c, role, why, cert, loc, n in D: @@ -2518,7 +2524,7 @@ def main(argv): print(f"\ndepends on (what it calls, so what may have to change with it): {len(seen_d)} callable(s)") if not seen_d and not libs: print(" nothing resolved" + (f" — but its body has {unres} unresolved call(s), so what it depends on is unknown, not nothing" if unres else "")) - for m, (cert, f, l) in sorted(seen_d.items(), key=lambda x: (CERT_RANK.get(x[1][0], 9), g.sym[x[0]]['display']))[:limit]: + for m, (cert, f, l) in sorted(seen_d.items(), key=lambda x: (CERT_RANK.get(x[1][0], 10), g.sym[x[0]]['display']))[:limit]: print(f" [{cert}] {g.disp(m)} {g.loc(m)}") if len(seen_d) > limit: print(f" … +{len(seen_d) - limit} (--limit N)") if libs: print(" into libraries (not traversed): " + ', '.join(str(x['lab']).split('(')[0] for x in libs[:6]) + (f" … +{len(libs) - 6}" if len(libs) > 6 else '')) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl index 04014cdbe..e659628e2 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl +++ b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl @@ -302,7 +302,8 @@ direct(q, c, "uses", cat("serves what this sends across a process boundary (", c // a provider does not depend on the handler asking for it, so it is claimed for none. // Certainty is `framework`, never `resolved`: the reason carries the mechanism, what joined the two ends and // the engine's own confidence (`registered`, or `by_name` for a fixture), so a reader can tell the hop from -// an invocation. A direct row only, not a seed of the closure: the contract `remote` keeps, for the same reason. +// an invocation. A direct row only, not a seed of the closure. (`remote` is walked by the closure, as the fw_edge +// tier `remote` below, from the sender to the handler only.) .decl framework(f:symbol, t:symbol, mech:symbol, detail:symbol, conf:symbol) .input framework direct(q, c, "uses", cat("framework-mediated, not a call: ", cat(mech, cat(" via ", cat(detail, cat(" [", cat(conf, "]")))))), "framework", "", 0) :- target(q, "method", m, _), framework(c, m, mech, detail, conf), c != m. @@ -876,6 +877,12 @@ fw_edge(a, b, "decorator") :- calls(a, m, _, _, _), decorated_name(m, b), a != b 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. +// 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 +// test route carries the `remote` rung, so it is never read as a call chain. One direction only: a handler does +// not depend on who sends to it. +fw_edge(a, b, "remote") :- remote(a, b, _, _, _), a != b. // THE PROTOCOL MEMBERS OF A TYPE YOU CONSTRUCT. `with Audit(...) as a`, `for line in audit`, `a == b`, `len(x)`, // `x[k]` — each runs a method, and none of them writes its name, so `__enter__`, `__iter__`, `__eq__` and their // siblings have no call site at all and a test that drives them looks unrelated. The one thing the graph does see diff --git a/tests/cases/java/cross-process-route/case.json b/tests/cases/java/cross-process-route/case.json new file mode 100644 index 000000000..65ebd15ee --- /dev/null +++ b/tests/cases/java/cross-process-route/case.json @@ -0,0 +1,22 @@ +{"lang": "java", "src": "src", + "checks": [ + {"why": "REGRESSION GUARD (#1293): a handler served under a route is depended on by the client that sends to it, as a remote hop naming its transport and destination", + "run": ["impact", "ItemController.replace"], + "want": ["[remote] ItemClient.update", "(http) at /api/items/{id}"]}, + {"why": "CONTROL: the GET handler on the same route is reached by no POST client", + "run": ["impact", "ItemController.get"], + "avoid": ["ItemClient.update", "[remote]"]}, + {"why": "path says the two ends are connected across a process, and by what", + "run": ["path", "ItemClient.update", "ItemController.replace"], + "want": ["connected across a process: ItemClient.update → ItemController.replace", "[http] at /api/items/{id} (exact)"], + "expect_error": true}, + {"why": "changed --impact on the handler's body names the client on the other side of the route", + "run": ["changed", "{repo}", "--old", "{repo}/old.java", "--new", "{repo}/new.java", "--file", "src/app/ItemController.java", "--impact"], + "want": ["body ItemController.replace", "[remote] ItemClient.update"]}, + {"why": "test-impact crosses the route: the test that drives the client is selected on the remote rung", + "run": ["test-impact", "{repo}", "--old", "{repo}/old.java", "--new", "{repo}/new.java", "--file", "src/app/ItemController.java", "--why"], + "want": ["ItemClientTest", "[remote]"]}, + {"why": "context lists the other end of a route as its own tier, never as a call hop", + "run": ["context", "what handles replacing an item"], + "want": ["across a process boundary", "[remote] http"]} + ]} diff --git a/tests/cases/java/cross-process-route/new.java b/tests/cases/java/cross-process-route/new.java new file mode 100644 index 000000000..70159c423 --- /dev/null +++ b/tests/cases/java/cross-process-route/new.java @@ -0,0 +1,21 @@ +package app; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/items") +public class ItemController { + + @GetMapping("/{id}") + public String get(String id) { + return id; + } + + @PostMapping("/{id}") + public String replace(String id) { + return id.trim(); + } +} diff --git a/tests/cases/java/cross-process-route/old.java b/tests/cases/java/cross-process-route/old.java new file mode 100644 index 000000000..91feccb24 --- /dev/null +++ b/tests/cases/java/cross-process-route/old.java @@ -0,0 +1,21 @@ +package app; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/items") +public class ItemController { + + @GetMapping("/{id}") + public String get(String id) { + return id; + } + + @PostMapping("/{id}") + public String replace(String id) { + return id; + } +} diff --git a/tests/cases/java/cross-process-route/src/app/ItemClient.java b/tests/cases/java/cross-process-route/src/app/ItemClient.java new file mode 100644 index 000000000..b64f8e51f --- /dev/null +++ b/tests/cases/java/cross-process-route/src/app/ItemClient.java @@ -0,0 +1,13 @@ +package app; + +import org.springframework.web.client.RestTemplate; + +public class ItemClient { + + private final RestTemplate restTemplate = new RestTemplate(); + + /** a POST to the route: reaches replace(), never get() */ + public String update(String id) { + return restTemplate.postForObject("http://items/api/items/{id}", id, String.class); + } +} diff --git a/tests/cases/java/cross-process-route/src/app/ItemController.java b/tests/cases/java/cross-process-route/src/app/ItemController.java new file mode 100644 index 000000000..91feccb24 --- /dev/null +++ b/tests/cases/java/cross-process-route/src/app/ItemController.java @@ -0,0 +1,21 @@ +package app; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/items") +public class ItemController { + + @GetMapping("/{id}") + public String get(String id) { + return id; + } + + @PostMapping("/{id}") + public String replace(String id) { + return id; + } +} diff --git a/tests/cases/java/cross-process-route/src/test/ItemClientTest.java b/tests/cases/java/cross-process-route/src/test/ItemClientTest.java new file mode 100644 index 000000000..4b8b3dc49 --- /dev/null +++ b/tests/cases/java/cross-process-route/src/test/ItemClientTest.java @@ -0,0 +1,12 @@ +package test; + +import app.ItemClient; +import org.junit.jupiter.api.Test; + +public class ItemClientTest { + + @Test + void updates() { + new ItemClient().update("7"); + } +} diff --git a/tests/cases/typescript/cross-process-route/case.json b/tests/cases/typescript/cross-process-route/case.json new file mode 100644 index 000000000..3f643a462 --- /dev/null +++ b/tests/cases/typescript/cross-process-route/case.json @@ -0,0 +1,24 @@ +{"lang": "typescript", "src": "src", + "checks": [ + {"why": "a handler served under a route is depended on by the client that sends to that route: the edge is a remote hop naming its transport and destination, not a call (#1293)", + "run": ["impact", "createOrder"], + "want": ["[remote] placeOrder", "across a process boundary (http) at /api/orders"], + "avoid": ["fetchOrder"]}, + {"why": "CONTROL: the same route under GET is served by another handler, and a POST client must not land on it", + "run": ["impact", "listOrders"], + "want": ["tests: 0 of 1"], + "avoid": ["placeOrder", "[remote]"]}, + {"why": "path says the two ends are connected across a process, and by what, when no call joins them", + "run": ["path", "placeOrder", "createOrder"], + "want": ["connected across a process: placeOrder → createOrder", "[http] at /api/orders (exact)"], + "expect_error": true}, + {"why": "changed --impact on the handler's body names the client on the other side of the route", + "run": ["changed", "{repo}", "--old", "{repo}/old.ts", "--new", "{repo}/new.ts", "--file", "src/server/orders.ts", "--impact"], + "want": ["body createOrder", "[remote] placeOrder", "(http) at /api/orders"]}, + {"why": "test-impact crosses the route: the test that drives the client is selected, on the remote rung, not as a call chain", + "run": ["test-impact", "{repo}", "--old", "{repo}/old.ts", "--new", "{repo}/new.ts", "--file", "src/server/orders.ts", "--why"], + "want": ["src/client/client.test.ts", "[remote]"]}, + {"why": "context lists the other end of a route as its own tier, with transport and destination, never as a call hop", + "run": ["context", "what handles creating an order"], + "want": ["across a process boundary", "[remote] http /api/orders (exact) placeOrder → createOrder"]} + ]} diff --git a/tests/cases/typescript/cross-process-route/new.ts b/tests/cases/typescript/cross-process-route/new.ts new file mode 100644 index 000000000..0fa301915 --- /dev/null +++ b/tests/cases/typescript/cross-process-route/new.ts @@ -0,0 +1,22 @@ +import { ORDERS } from '../shared/paths'; + +declare const app: { + get(path: string, h: unknown): void; + post(path: string, h: unknown): void; +}; + +export function listOrders(): string { + return 'all'; +} + +export function createOrder(): string { + return ['created', 'now'].join(' '); +} + +export function getOrder(): string { + return 'one'; +} + +app.get(ORDERS, listOrders); +app.post(ORDERS, createOrder); +app.get('/api/orders/:id', getOrder); diff --git a/tests/cases/typescript/cross-process-route/old.ts b/tests/cases/typescript/cross-process-route/old.ts new file mode 100644 index 000000000..7413eb5d2 --- /dev/null +++ b/tests/cases/typescript/cross-process-route/old.ts @@ -0,0 +1,22 @@ +import { ORDERS } from '../shared/paths'; + +declare const app: { + get(path: string, h: unknown): void; + post(path: string, h: unknown): void; +}; + +export function listOrders(): string { + return 'all'; +} + +export function createOrder(): string { + return 'created'; +} + +export function getOrder(): string { + return 'one'; +} + +app.get(ORDERS, listOrders); +app.post(ORDERS, createOrder); +app.get('/api/orders/:id', getOrder); diff --git a/tests/cases/typescript/cross-process-route/src/client/axios.d.ts b/tests/cases/typescript/cross-process-route/src/client/axios.d.ts new file mode 100644 index 000000000..6bef28b59 --- /dev/null +++ b/tests/cases/typescript/cross-process-route/src/client/axios.d.ts @@ -0,0 +1,8 @@ +declare module 'axios' { + interface HttpStatic { + get(url: string, config?: unknown): Promise; + post(url: string, data?: unknown, config?: unknown): Promise; + } + const axios: HttpStatic; + export default axios; +} diff --git a/tests/cases/typescript/cross-process-route/src/client/client.test.ts b/tests/cases/typescript/cross-process-route/src/client/client.test.ts new file mode 100644 index 000000000..b024863b7 --- /dev/null +++ b/tests/cases/typescript/cross-process-route/src/client/client.test.ts @@ -0,0 +1,6 @@ +import { it, expect } from 'vitest'; +import { placeOrder } from './client'; + +it('places an order', async () => { + expect(await placeOrder({ sku: 'a' })).toBeDefined(); +}); diff --git a/tests/cases/typescript/cross-process-route/src/client/client.ts b/tests/cases/typescript/cross-process-route/src/client/client.ts new file mode 100644 index 000000000..5e9d947b7 --- /dev/null +++ b/tests/cases/typescript/cross-process-route/src/client/client.ts @@ -0,0 +1,8 @@ +import axios from 'axios'; +import { ORDERS } from '../shared/paths'; + +// POST /api/orders: reaches createOrder across the process, and never listOrders (GET) +export async function placeOrder(body: unknown) { return axios.post(ORDERS, body); } + +// GET /api/orders/{}: reaches getOrder +export async function fetchOrder(id: string) { return fetch(`/api/orders/${id}`); } diff --git a/tests/cases/typescript/cross-process-route/src/server/orders.ts b/tests/cases/typescript/cross-process-route/src/server/orders.ts new file mode 100644 index 000000000..7413eb5d2 --- /dev/null +++ b/tests/cases/typescript/cross-process-route/src/server/orders.ts @@ -0,0 +1,22 @@ +import { ORDERS } from '../shared/paths'; + +declare const app: { + get(path: string, h: unknown): void; + post(path: string, h: unknown): void; +}; + +export function listOrders(): string { + return 'all'; +} + +export function createOrder(): string { + return 'created'; +} + +export function getOrder(): string { + return 'one'; +} + +app.get(ORDERS, listOrders); +app.post(ORDERS, createOrder); +app.get('/api/orders/:id', getOrder); diff --git a/tests/cases/typescript/cross-process-route/src/shared/paths.ts b/tests/cases/typescript/cross-process-route/src/shared/paths.ts new file mode 100644 index 000000000..b6b7246f7 --- /dev/null +++ b/tests/cases/typescript/cross-process-route/src/shared/paths.ts @@ -0,0 +1 @@ +export const ORDERS = '/api/orders';