diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context index 160637dc7..10c6cb9f8 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context @@ -9,7 +9,8 @@ axiomcode's engine, and the answer's first line says so, why, and how to prevent A task that asks HOW something works ("how does X …", "explain …", "what happens when …", or --explain) also gets the call flow: the spine of the mechanism in the order the calls are written, each step with its edge's certainty, the line that makes the call, and the calls in its body the graph could not resolve. --from starts the flow -there instead of at the entry the task's words choose. +there instead of at the entry the task's words choose. Utility code (a widely-called helper package that calls no domain +step: logging, metrics, string and format helpers, serializers) is named on its caller's `also calls` line, not walked. THE FRONT DOOR — the context an agent needs before it can ask anything precise. Every other verb needs a name you already have — a method, a type, a file:line. That is the wrong first question on an unfamiliar repository, and it is measurably where an agent gives up: in a @@ -787,6 +788,7 @@ CONTAINER = frozenset('items keys values get pop popitem append extend add updat DISPATCH_NAMED = 6 # the registered handlers a dispatch point names; the rest are counted HANDOFF = {} # (step, registration line, file) -> the declaration that registered it ENTRY_NOTE = {} # entry step -> why the flow starts there, when it is not a match of the task's words +ALSO = {} # step -> [(utility callable, call sites)] collapsed onto one line under it (see UTILITY) def param_names(g, x): @@ -944,7 +946,67 @@ def made_by(g, x): return best[1] if best else None -def flow(g, roots, depth_cap=FLOW_DEPTH, max_steps=FLOW_STEPS, shallow=frozenset()): +# ── UTILITY: logging, metrics, string and format helpers, serializers are not steps of a mechanism ─────────────────── +# Asked how a feature works, a reader wants the domain steps; a walk that descends into every logger, metrics counter, +# string helper and serializer it passes buries them (reported: half of a cancel flow's steps were such helpers). They +# are DEMOTED, never hidden: not walked, and named on one line under the step that calls them. +# What makes code utility is read from the graph's SHAPE, not from its names: +# * its PACKAGE (the directory) is a bottom layer: no resolved call leaves it for project code other than another +# utility package. A data-model or repository file is a bottom layer too, but its directory holds the services +# that call upward, so the package is not; a helpers package holds nothing else; +# * the package is called from at least UTILITY_DIRS other directories: what every layer reaches for; +# * the callable is a SINK in the flow: nothing it calls is a domain step. A retry decorator's wrapper, which runs +# the function it wraps, stays a step because the domain call is beyond it; its backoff helper collapses. +# Names only break a tie: a package or file named for logging or metrics needs UTILITY_DIRS_NAMED directories. +# And a task that is ABOUT the utility walks into it: a callable whose own name, class, file or directory carries one +# of the task's words is never demoted, nor is anything in a file the flow starts in. +UTILITY_DIRS, UTILITY_DIRS_NAMED = 3, 2 +UTILITY_ROLE = frozenset('log logs logger logging metric metrics stats statsd telemetry tracing'.split()) + + +def _pkg(f): + return (f or '').rsplit('/', 1)[0] if '/' in (f or '') else '' + + +def utility_steps(g, rows, out, roots, terms, project): + """the callables a flow collapses instead of walking (see UTILITY above).""" + testy = lambda x: (g.sym.get(x) or {}).get('is_test') or TESTY.search((g.sym.get(x) or {}).get('file') or '') + into, outof, files = collections.defaultdict(set), collections.defaultdict(set), collections.defaultdict(set) + for r in rows: + c, m = r['c'], r['m'] + if not m or m == c or not project(m) or not project(c) or testy(c) or testy(m): continue + if ax_edges.direct_cert(r['t']) != 'resolved': continue # a callback run through a value is not a dependency + cp, mp = _pkg(g.sym[c].get('file')), _pkg(g.sym[m].get('file')) + if cp == mp: continue + into[mp].add(cp); outof[cp].add(mp) + for x, sy in g.sym.items(): + if sy.get('method_id') and project(x) and not testy(x): files[_pkg(sy.get('file'))].add((sy.get('file') or '').rsplit('/', 1)[-1]) + def named(p): + return any(t in UTILITY_ROLE for t in subtokens(p.rsplit('/', 1)[-1]) + [t for f in files[p] for t in subtokens(f.rsplit('.', 1)[0])]) + pkgs = {p for p in into if p and len(into[p]) >= (UTILITY_DIRS_NAMED if named(p) else UTILITY_DIRS)} + while True: # a bottom layer: calls out only to other utility packages + drop = {p for p in pkgs if outof[p] - pkgs} + if not drop: break + pkgs -= drop + if not pkgs: return set() + tset, tstem = set(terms), {stem(t) for t in terms} + root_files = {(g.sym.get(x) or {}).get('file') for x in roots} + def asked(x): + sy = g.sym[x]; f = sy.get('file') or '' + toks = set(subtokens(sy.get('name') or '') + subtokens(sy.get('display') or '') + + subtokens(f.rsplit('/', 1)[-1].rsplit('.', 1)[0]) + subtokens(_pkg(f).rsplit('/', 1)[-1])) + return bool(toks & tset or {stem(t) for t in toks} & tstem) + cand = {y for ys in out.values() for _l, y, _t, _f in ys} + util = {y for y in cand if y not in roots and _pkg(g.sym[y].get('file')) in pkgs + and g.sym[y].get('file') not in root_files and not asked(y)} + while True: # a sink: nothing it calls is a domain step + drop = {y for y in util if any(z not in util and z != y for _l, z, _t, _f in out.get(y, ()))} + if not drop: break + util -= drop + return util + + +def flow(g, roots, depth_cap=FLOW_DEPTH, max_steps=FLOW_STEPS, shallow=frozenset(), terms=()): """The call flow from `roots`, in the order the calls are written. Nothing here is ranked or scored. Where a flow STARTS is the reader's decision (--from) or the entry points this verb already chose; which of a @@ -1014,13 +1076,14 @@ def flow(g, roots, depth_cap=FLOW_DEPTH, max_steps=FLOW_STEPS, shallow=frozenset # WHICH steps are shown is chosen breadth-first: the entry point's own calls are the outline of the mechanism and # their callees are detail, so the budget goes to every call of the root before any call of a call. Chosen # depth-first, one helper's subtree took the whole budget before the flow reached the root's later calls. + util = utility_steps(g, rows, out, roots, terms, project) keep, frontier = set(roots), list(roots) for _d in range(depth_cap): nxt = [] for x in frontier: if x in shallow: continue for _l, y, _t, _f in out.get(x, ()): - if y not in keep and len(keep) < max_steps: + if y not in keep and y not in util and len(keep) < max_steps: keep.add(y); nxt.append(y) frontier = nxt if not frontier or len(keep) >= max_steps: break @@ -1059,6 +1122,14 @@ def flow(g, roots, depth_cap=FLOW_DEPTH, max_steps=FLOW_STEPS, shallow=frozenset + ("; bound as text in " + ', '.join(f"{f}:{l}" for f, l, _d, _n in bound) if bound else f"; `impact {g.disp(x)}` lists what implements it")) if m: marks[x] = m + # the utility calls a step makes, collapsed onto one line under it: `name ×sites`, in the order first written + also = {} + for x in {x for _d, x, _v, again in steps if again is None}: + seen_ = collections.OrderedDict() + for l, y, _t, _f in out.get(x, ()): + if y in util: seen_.setdefault(y, set()).add(l) + if seen_: also[x] = [(y, len(ls)) for y, ls in seen_.items()] + ALSO.clear(); ALSO.update(also) return roots, steps, gaps, marks @@ -1133,6 +1204,9 @@ def print_flow(g, roots, steps, gaps, chosen, show_source=False, marks=None): print(f" {pad} ⚠ unresolved here: " + ', '.join(f"{nm}() L{l}" for l, nm in gs)) for mk in (marks or {}).get(x, ()): print(f" {pad} ⚠ {mk}") + if ALSO.get(x): + print(f" {pad} also calls (utility, not walked): " + + ', '.join(g.disp(y) + (f" ×{k}" if k > 1 else '') for y, k in ALSO[x])) sy = g.sym.get(x) or {} if show_source and code_left[0] <= 0 and not code_cut[0]: code_cut[0] = True @@ -1145,6 +1219,9 @@ def print_flow(g, roots, steps, gaps, chosen, show_source=False, marks=None): print(row) if b > a + FLOW_SOURCE_LINES - 1: print(f" {pad} {'':5} | … +{b - a - FLOW_SOURCE_LINES + 1} more line(s) — open {sy['file']}:{a} for the rest") + if ALSO: + print(" utility calls (a widely-called helper package for logging, metrics, string, format or serialization, that calls " + "no domain step) are named on their caller's `also calls` line, not walked; `--from ` walks into one") if code_cut[0]: print(" the later steps are named without their code, to keep this answer to one page; " "`--from ` shows any of them with its code") @@ -1411,7 +1488,7 @@ def main(argv): # a producer joined only by name is shown as one step: its code holds the call, and the budget goes to the # mechanism the task asked about rather than to everything else the producer does roots, steps, gaps, marks = flow(g, roots_, max_steps=FLOW_SOURCE_STEPS if show_source else FLOW_STEPS, - shallow=frozenset(ENTRY_NOTE)) + shallow=frozenset(ENTRY_NOTE), terms=terms) RESULT['flow'] = [{'step': k + 1, 'depth': d, 'name': g.disp(x), 'at': g.loc(x), 'called_at_line': (via[0] if via else None), 'certainty': (ax_edges.direct_cert(via[1]) if via else 'entry'), 'repeat_of': again, 'unresolved': [f"{nm} L{l}" for l, nm in gaps.get(x, ())[:4]], @@ -1419,7 +1496,9 @@ def main(argv): 'why_here': (ENTRY_NOTE.get(x) if not via else None), 'registered_by': (g.disp(HANDOFF[(x, via[0], via[2])]) if via and via[1] == 'handoff' and (x, via[0], via[2]) in HANDOFF else None), - 'leaves_graph': (marks.get(x, []) if again is None else [])} + 'leaves_graph': (marks.get(x, []) if again is None else []), + 'also_calls': ([{'name': g.disp(y), 'at': g.loc(y), 'sites': k} for y, k in ALSO.get(x, ())] + if again is None else [])} for k, (d, x, via, again) in enumerate(steps)] if not print_flow(g, roots, steps, gaps, bool(starts), show_source, marks): # the entry points and files were held back because the flow was to BE the answer; with no flow they are diff --git a/tests/cases/python/context-utility-collapses-under-its-caller/case.json b/tests/cases/python/context-utility-collapses-under-its-caller/case.json new file mode 100644 index 000000000..2ad1c7903 --- /dev/null +++ b/tests/cases/python/context-utility-collapses-under-its-caller/case.json @@ -0,0 +1,19 @@ +{"lang": "python", "src": ".", + "checks": [ + {"why": "a narrative walk does not descend into logging, metrics, string, serialization and retry helpers: shop/utils and shop/observability are called from every other package and call nothing back, so their callables collapse onto one `also calls` line under the step that calls them, and the domain steps come first (step 2 is make_service, not the metrics counter)", + "run": ["context", "how is an order cancelled", "--from", "cancel_order_endpoint"], + "want": [" 2 → make_service ", + "also calls (utility, not walked): increment, normalize_id, to_json", + "→ OrderCancellationService.cancel_order ", + "also calls (utility, not walked): truncate, increment, log_event, format_money, Metrics.timing", + "→ OrderRepository.get ", "→ OrderCancellationService.check_cancellable ", "→ OrderCancellationService.release_payment ", + "⇢ PaymentClient.refund ", "⇢ PaymentClient.void ", "→ OrderRepository.save ", "→ OrderCancellationService.notify_customer "], + "avoid": ["→ increment ", "→ normalize_id ", "→ truncate ", "→ log_event ", "→ to_json ", "→ format_money ", "↺ increment"]}, + {"why": "the retry decorator's wrapper is utility code with a domain call beyond it, so it stays a step and the flow reaches the payment client through it; only its own backoff helper collapses", + "run": ["context", "how is an order cancelled", "--from", "cancel_order_endpoint"], + "want": ["→ inner ", "also calls (utility, not walked): backoff"], + "avoid": ["→ backoff "]}, + {"why": "CONTROL: a question ABOUT logging still walks into the logging helper: the task's word lands in its file's name, so it is a step, as it was before utility code was collapsed", + "run": ["context", "how is a cancellation logged", "--from", "cancel_order"], + "want": ["→ log_event "], + "avoid": ["not walked): truncate, increment, log_event"]}]} diff --git a/tests/cases/python/context-utility-collapses-under-its-caller/shop/__init__.py b/tests/cases/python/context-utility-collapses-under-its-caller/shop/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cases/python/context-utility-collapses-under-its-caller/shop/api/__init__.py b/tests/cases/python/context-utility-collapses-under-its-caller/shop/api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cases/python/context-utility-collapses-under-its-caller/shop/api/orders.py b/tests/cases/python/context-utility-collapses-under-its-caller/shop/api/orders.py new file mode 100644 index 000000000..eb497df00 --- /dev/null +++ b/tests/cases/python/context-utility-collapses-under-its-caller/shop/api/orders.py @@ -0,0 +1,25 @@ +from shop.clients.payment import PaymentClient +from shop.observability.logging_setup import get_logger +from shop.observability.metrics import increment +from shop.repository.orders import OrderRepository +from shop.service.cancellation import CancellationError, OrderCancellationService +from shop.utils.serialization import to_json +from shop.utils.strings import normalize_id + +log = get_logger(__name__) + + +def make_service(transport, notifier): + return OrderCancellationService(OrderRepository(), PaymentClient(transport), notifier) + + +def cancel_order_endpoint(request, transport, notifier): + increment("api.cancel") + order_id = normalize_id(request["order_id"]) + service = make_service(transport, notifier) + try: + order = service.cancel_order(order_id, request.get("reason", "")) + except CancellationError as e: + log.warning("cancel refused %s", e) + return 409, {"error": str(e)} + return 200, to_json(order) diff --git a/tests/cases/python/context-utility-collapses-under-its-caller/shop/api/reports.py b/tests/cases/python/context-utility-collapses-under-its-caller/shop/api/reports.py new file mode 100644 index 000000000..b3cb6d371 --- /dev/null +++ b/tests/cases/python/context-utility-collapses-under-its-caller/shop/api/reports.py @@ -0,0 +1,10 @@ +from shop.observability.metrics import increment +from shop.repository.orders import OrderRepository +from shop.utils.serialization import to_json +from shop.utils.strings import format_money, normalize_id + + +def order_summary(request): + increment("api.summary") + order = OrderRepository().get(normalize_id(request["order_id"])) + return {"total": format_money(order.amount_cents), "raw": to_json(order)} diff --git a/tests/cases/python/context-utility-collapses-under-its-caller/shop/clients/__init__.py b/tests/cases/python/context-utility-collapses-under-its-caller/shop/clients/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cases/python/context-utility-collapses-under-its-caller/shop/clients/payment.py b/tests/cases/python/context-utility-collapses-under-its-caller/shop/clients/payment.py new file mode 100644 index 000000000..e15de1397 --- /dev/null +++ b/tests/cases/python/context-utility-collapses-under-its-caller/shop/clients/payment.py @@ -0,0 +1,35 @@ +from shop.observability.logging_setup import get_logger, log_event +from shop.observability.metrics import increment +from shop.utils.retry import retry +from shop.utils.serialization import to_json + +log = get_logger(__name__) + + +class PaymentClient: + def __init__(self, transport): + self.transport = transport + + @retry(times=3) + def refund(self, payment_id, amount_cents): + increment("payment.refund") + log_event(log, "refund", payment_id=payment_id, amount=amount_cents) + body = to_json(RefundRequest(payment_id, amount_cents)) + return self.transport.post("/refunds", body) + + @retry(times=3) + def void(self, payment_id): + increment("payment.void") + log.info("void %s", payment_id) + return self.transport.post("/voids", to_json(VoidRequest(payment_id))) + + +class RefundRequest: + def __init__(self, payment_id, amount_cents): + self.payment_id = payment_id + self.amount_cents = amount_cents + + +class VoidRequest: + def __init__(self, payment_id): + self.payment_id = payment_id diff --git a/tests/cases/python/context-utility-collapses-under-its-caller/shop/observability/__init__.py b/tests/cases/python/context-utility-collapses-under-its-caller/shop/observability/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cases/python/context-utility-collapses-under-its-caller/shop/observability/logging_setup.py b/tests/cases/python/context-utility-collapses-under-its-caller/shop/observability/logging_setup.py new file mode 100644 index 000000000..28cf4662b --- /dev/null +++ b/tests/cases/python/context-utility-collapses-under-its-caller/shop/observability/logging_setup.py @@ -0,0 +1,15 @@ +import logging + + +def get_logger(name): + logger = logging.getLogger(name) + logger.setLevel(logging.INFO) + return logger + + +def log_event(logger, event, **fields): + logger.info("%s %s", event, format_fields(fields)) + + +def format_fields(fields): + return " ".join(f"{k}={v}" for k, v in sorted(fields.items())) diff --git a/tests/cases/python/context-utility-collapses-under-its-caller/shop/observability/metrics.py b/tests/cases/python/context-utility-collapses-under-its-caller/shop/observability/metrics.py new file mode 100644 index 000000000..24028277f --- /dev/null +++ b/tests/cases/python/context-utility-collapses-under-its-caller/shop/observability/metrics.py @@ -0,0 +1,19 @@ +import time + + +class Metrics: + def __init__(self): + self.counts = {} + + def increment(self, key, value=1): + self.counts[key] = self.counts.get(key, 0) + value + + def timing(self, key, started): + self.counts[key + ".ms"] = (time.time() - started) * 1000 + + +metrics = Metrics() + + +def increment(key, value=1): + metrics.increment(key, value) diff --git a/tests/cases/python/context-utility-collapses-under-its-caller/shop/repository/__init__.py b/tests/cases/python/context-utility-collapses-under-its-caller/shop/repository/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cases/python/context-utility-collapses-under-its-caller/shop/repository/orders.py b/tests/cases/python/context-utility-collapses-under-its-caller/shop/repository/orders.py new file mode 100644 index 000000000..50610d8ad --- /dev/null +++ b/tests/cases/python/context-utility-collapses-under-its-caller/shop/repository/orders.py @@ -0,0 +1,29 @@ +from shop.observability.logging_setup import get_logger +from shop.observability.metrics import increment +from shop.utils.strings import normalize_id + +log = get_logger(__name__) + + +class Order: + def __init__(self, order_id, amount_cents, status="open", payment_id=None): + self.order_id = order_id + self.amount_cents = amount_cents + self.status = status + self.payment_id = payment_id + + +class OrderRepository: + def __init__(self): + self.rows = {} + + def get(self, order_id): + key = normalize_id(order_id) + increment("repo.get") + log.debug("loading order %s", key) + return self.rows.get(key) + + def save(self, order): + increment("repo.save") + log.debug("saving order %s", order.order_id) + self.rows[normalize_id(order.order_id)] = order diff --git a/tests/cases/python/context-utility-collapses-under-its-caller/shop/service/__init__.py b/tests/cases/python/context-utility-collapses-under-its-caller/shop/service/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cases/python/context-utility-collapses-under-its-caller/shop/service/cancellation.py b/tests/cases/python/context-utility-collapses-under-its-caller/shop/service/cancellation.py new file mode 100644 index 000000000..65b2c25a3 --- /dev/null +++ b/tests/cases/python/context-utility-collapses-under-its-caller/shop/service/cancellation.py @@ -0,0 +1,53 @@ +import time + +from shop.clients.payment import PaymentClient +from shop.observability.logging_setup import get_logger, log_event +from shop.observability.metrics import increment, metrics +from shop.repository.orders import OrderRepository +from shop.utils.strings import format_money, truncate + +log = get_logger(__name__) + + +class CancellationError(Exception): + pass + + +class OrderCancellationService: + def __init__(self, repo: OrderRepository, payments: PaymentClient, notifier): + self.repo = repo + self.payments = payments + self.notifier = notifier + + def cancel_order(self, order_id, reason): + started = time.time() + log.info("cancel requested %s: %s", order_id, truncate(reason)) + increment("orders.cancel.requested") + order = self.repo.get(order_id) + self.check_cancellable(order) + self.release_payment(order) + order.status = "cancelled" + self.repo.save(order) + self.notify_customer(order, reason) + log_event(log, "cancelled", order=order.order_id, amount=format_money(order.amount_cents)) + metrics.timing("orders.cancel", started) + return order + + def check_cancellable(self, order): + if order is None: + raise CancellationError("no such order") + if order.status in ("shipped", "cancelled"): + increment("orders.cancel.rejected") + raise CancellationError(f"cannot cancel a {order.status} order") + + def release_payment(self, order): + if not order.payment_id: + return + if order.status == "captured": + self.payments.refund(order.payment_id, order.amount_cents) + else: + self.payments.void(order.payment_id) + + def notify_customer(self, order, reason): + log.debug("notify %s", order.order_id) + self.notifier.send(order.order_id, "Your order was cancelled: " + truncate(reason, 40)) diff --git a/tests/cases/python/context-utility-collapses-under-its-caller/shop/utils/__init__.py b/tests/cases/python/context-utility-collapses-under-its-caller/shop/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cases/python/context-utility-collapses-under-its-caller/shop/utils/retry.py b/tests/cases/python/context-utility-collapses-under-its-caller/shop/utils/retry.py new file mode 100644 index 000000000..c450bd373 --- /dev/null +++ b/tests/cases/python/context-utility-collapses-under-its-caller/shop/utils/retry.py @@ -0,0 +1,22 @@ +import functools +import time + + +def retry(times=3, delay=0.1): + def wrap(fn): + @functools.wraps(fn) + def inner(*args, **kwargs): + last = None + for attempt in range(times): + try: + return fn(*args, **kwargs) + except IOError as e: + last = e + backoff(attempt, delay) + raise last + return inner + return wrap + + +def backoff(attempt, delay): + time.sleep(delay * (2 ** attempt)) diff --git a/tests/cases/python/context-utility-collapses-under-its-caller/shop/utils/serialization.py b/tests/cases/python/context-utility-collapses-under-its-caller/shop/utils/serialization.py new file mode 100644 index 000000000..46cfd82a1 --- /dev/null +++ b/tests/cases/python/context-utility-collapses-under-its-caller/shop/utils/serialization.py @@ -0,0 +1,9 @@ +import json + + +def to_json(obj): + return json.dumps(to_dict(obj), sort_keys=True) + + +def to_dict(obj): + return {k: v for k, v in vars(obj).items() if not k.startswith("_")} diff --git a/tests/cases/python/context-utility-collapses-under-its-caller/shop/utils/strings.py b/tests/cases/python/context-utility-collapses-under-its-caller/shop/utils/strings.py new file mode 100644 index 000000000..54a5d48b5 --- /dev/null +++ b/tests/cases/python/context-utility-collapses-under-its-caller/shop/utils/strings.py @@ -0,0 +1,11 @@ +def normalize_id(value): + return str(value).strip().lower() + + +def truncate(text, limit=80): + text = str(text) + return text if len(text) <= limit else text[: limit - 1] + "..." + + +def format_money(cents): + return f"{cents / 100:.2f}" diff --git a/tests/cases/typescript/context-utility-collapses-under-its-caller/case.json b/tests/cases/typescript/context-utility-collapses-under-its-caller/case.json new file mode 100644 index 000000000..eb394b98e --- /dev/null +++ b/tests/cases/typescript/context-utility-collapses-under-its-caller/case.json @@ -0,0 +1,19 @@ +{"lang": "typescript", "src": "src", + "checks": [ + {"why": "a narrative walk does not descend into logging, metrics, string and serialization helpers: utils/ and observability/ are called from every other package and call nothing back, so their functions collapse onto one `also calls` line under the step that calls them, and the domain steps come first (step 2 is makeService, not the metrics counter)", + "run": ["context", "how is an order cancelled", "--from", "cancelOrderEndpoint"], + "want": [" 2 → makeService ", + "also calls (utility, not walked): increment, normalizeId, toJson, logInfo", + "→ OrderCancellationService.cancelOrder ", + "also calls (utility, not walked): logInfo ×2, truncate, increment, formatMoney, Metrics.timing", + "→ OrderRepository.get ", "→ OrderCancellationService.checkCancellable ", "→ OrderCancellationService.releasePayment ", + "→ PaymentClient.refund ", "→ PaymentClient.voidPayment ", "→ OrderRepository.save ", "→ OrderCancellationService.notifyCustomer "], + "avoid": ["→ increment ", "→ normalizeId ", "→ truncate ", "→ logInfo ", "→ formatFields ", "→ toJson ", "→ formatMoney ", "↺ increment"]}, + {"why": "the retry helper runs the function it is handed, so it stays a step; only its own backoff collapses", + "run": ["context", "how is an order cancelled", "--from", "cancelOrderEndpoint"], + "want": ["→ withRetry ", "also calls (utility, not walked): backoff"], + "avoid": ["→ backoff "]}, + {"why": "CONTROL: a question ABOUT logging still walks into the logger: the task's word lands in its file's name, so it is a step, as it was before utility code was collapsed", + "run": ["context", "how is a cancellation logged", "--from", "cancelOrder"], + "want": ["→ logInfo "], + "avoid": ["not walked): logInfo"]}]} diff --git a/tests/cases/typescript/context-utility-collapses-under-its-caller/src/api/orders.ts b/tests/cases/typescript/context-utility-collapses-under-its-caller/src/api/orders.ts new file mode 100644 index 000000000..be4f97f28 --- /dev/null +++ b/tests/cases/typescript/context-utility-collapses-under-its-caller/src/api/orders.ts @@ -0,0 +1,31 @@ +import { PaymentClient, Transport } from "../clients/payment"; +import { increment } from "../observability/metrics"; +import { logInfo } from "../observability/logger"; +import { OrderRepository } from "../repository/orders"; +import { CancellationError, Notifier, OrderCancellationService } from "../service/cancellation"; +import { toJson } from "../utils/serialization"; +import { normalizeId } from "../utils/strings"; + +export function makeService(transport: Transport, notifier: Notifier): OrderCancellationService { + return new OrderCancellationService(new OrderRepository(), new PaymentClient(transport), notifier); +} + +export async function cancelOrderEndpoint( + request: { orderId: string; reason?: string }, + transport: Transport, + notifier: Notifier, +): Promise<[number, string]> { + increment("api.cancel"); + const orderId = normalizeId(request.orderId); + const service = makeService(transport, notifier); + try { + const order = await service.cancelOrder(orderId, request.reason ?? ""); + return [200, toJson(order)]; + } catch (e) { + if (e instanceof CancellationError) { + logInfo("cancel refused", { error: e.message }); + return [409, e.message]; + } + throw e; + } +} diff --git a/tests/cases/typescript/context-utility-collapses-under-its-caller/src/api/reports.ts b/tests/cases/typescript/context-utility-collapses-under-its-caller/src/api/reports.ts new file mode 100644 index 000000000..c576bfded --- /dev/null +++ b/tests/cases/typescript/context-utility-collapses-under-its-caller/src/api/reports.ts @@ -0,0 +1,10 @@ +import { increment } from "../observability/metrics"; +import { OrderRepository } from "../repository/orders"; +import { toJson } from "../utils/serialization"; +import { formatMoney, normalizeId } from "../utils/strings"; + +export function orderSummary(request: { orderId: string }): { total: string; raw: string } { + increment("api.summary"); + const order = new OrderRepository().get(normalizeId(request.orderId))!; + return { total: formatMoney(order.amountCents), raw: toJson(order) }; +} diff --git a/tests/cases/typescript/context-utility-collapses-under-its-caller/src/clients/payment.ts b/tests/cases/typescript/context-utility-collapses-under-its-caller/src/clients/payment.ts new file mode 100644 index 000000000..349961ad9 --- /dev/null +++ b/tests/cases/typescript/context-utility-collapses-under-its-caller/src/clients/payment.ts @@ -0,0 +1,25 @@ +import { increment } from "../observability/metrics"; +import { logInfo } from "../observability/logger"; +import { withRetry } from "../utils/retry"; +import { toJson } from "../utils/serialization"; + +export interface Transport { + post(path: string, body: string): Promise; +} + +export class PaymentClient { + constructor(private transport: Transport) {} + + async refund(paymentId: string, amountCents: number): Promise { + increment("payment.refund"); + logInfo("refund", { paymentId, amountCents }); + const body = toJson({ paymentId, amountCents }); + return withRetry(() => this.transport.post("/refunds", body)); + } + + async voidPayment(paymentId: string): Promise { + increment("payment.void"); + logInfo("void", { paymentId }); + return withRetry(() => this.transport.post("/voids", toJson({ paymentId }))); + } +} diff --git a/tests/cases/typescript/context-utility-collapses-under-its-caller/src/observability/logger.ts b/tests/cases/typescript/context-utility-collapses-under-its-caller/src/observability/logger.ts new file mode 100644 index 000000000..39cb8bb15 --- /dev/null +++ b/tests/cases/typescript/context-utility-collapses-under-its-caller/src/observability/logger.ts @@ -0,0 +1,11 @@ +export function logInfo(event: string, fields: Record = {}): void { + console.log(`${event} ${formatFields(fields)}`); +} + +export function logDebug(event: string): void { + console.debug(event); +} + +export function formatFields(fields: Record): string { + return Object.keys(fields).sort().map((k) => `${k}=${String(fields[k])}`).join(" "); +} diff --git a/tests/cases/typescript/context-utility-collapses-under-its-caller/src/observability/metrics.ts b/tests/cases/typescript/context-utility-collapses-under-its-caller/src/observability/metrics.ts new file mode 100644 index 000000000..7231ecf15 --- /dev/null +++ b/tests/cases/typescript/context-utility-collapses-under-its-caller/src/observability/metrics.ts @@ -0,0 +1,17 @@ +export class Metrics { + private counts: Record = {}; + + increment(key: string, value = 1): void { + this.counts[key] = (this.counts[key] ?? 0) + value; + } + + timing(key: string, started: number): void { + this.counts[key + ".ms"] = Date.now() - started; + } +} + +export const metrics = new Metrics(); + +export function increment(key: string, value = 1): void { + metrics.increment(key, value); +} diff --git a/tests/cases/typescript/context-utility-collapses-under-its-caller/src/repository/orders.ts b/tests/cases/typescript/context-utility-collapses-under-its-caller/src/repository/orders.ts new file mode 100644 index 000000000..2575549ea --- /dev/null +++ b/tests/cases/typescript/context-utility-collapses-under-its-caller/src/repository/orders.ts @@ -0,0 +1,29 @@ +import { increment } from "../observability/metrics"; +import { logDebug } from "../observability/logger"; +import { normalizeId } from "../utils/strings"; + +export class Order { + constructor( + public orderId: string, + public amountCents: number, + public status = "open", + public paymentId?: string, + ) {} +} + +export class OrderRepository { + private rows = new Map(); + + get(orderId: string): Order | undefined { + const key = normalizeId(orderId); + increment("repo.get"); + logDebug("loading order " + key); + return this.rows.get(key); + } + + save(order: Order): void { + increment("repo.save"); + logDebug("saving order " + order.orderId); + this.rows.set(normalizeId(order.orderId), order); + } +} diff --git a/tests/cases/typescript/context-utility-collapses-under-its-caller/src/service/cancellation.ts b/tests/cases/typescript/context-utility-collapses-under-its-caller/src/service/cancellation.ts new file mode 100644 index 000000000..8c0f00335 --- /dev/null +++ b/tests/cases/typescript/context-utility-collapses-under-its-caller/src/service/cancellation.ts @@ -0,0 +1,52 @@ +import { PaymentClient } from "../clients/payment"; +import { logInfo, logDebug } from "../observability/logger"; +import { increment, metrics } from "../observability/metrics"; +import { Order, OrderRepository } from "../repository/orders"; +import { formatMoney, truncate } from "../utils/strings"; + +export interface Notifier { + send(orderId: string, message: string): void; +} + +export class CancellationError extends Error {} + +export class OrderCancellationService { + constructor(private repo: OrderRepository, private payments: PaymentClient, private notifier: Notifier) {} + + async cancelOrder(orderId: string, reason: string): Promise { + const started = Date.now(); + logInfo("cancel requested", { orderId, reason: truncate(reason) }); + increment("orders.cancel.requested"); + const order = this.repo.get(orderId); + this.checkCancellable(order); + await this.releasePayment(order!); + order!.status = "cancelled"; + this.repo.save(order!); + this.notifyCustomer(order!, reason); + logInfo("cancelled", { order: order!.orderId, amount: formatMoney(order!.amountCents) }); + metrics.timing("orders.cancel", started); + return order!; + } + + checkCancellable(order: Order | undefined): void { + if (!order) throw new CancellationError("no such order"); + if (order.status === "shipped" || order.status === "cancelled") { + increment("orders.cancel.rejected"); + throw new CancellationError(`cannot cancel a ${order.status} order`); + } + } + + async releasePayment(order: Order): Promise { + if (!order.paymentId) return; + if (order.status === "captured") { + await this.payments.refund(order.paymentId, order.amountCents); + } else { + await this.payments.voidPayment(order.paymentId); + } + } + + notifyCustomer(order: Order, reason: string): void { + logDebug("notify " + order.orderId); + this.notifier.send(order.orderId, "Your order was cancelled: " + truncate(reason, 40)); + } +} diff --git a/tests/cases/typescript/context-utility-collapses-under-its-caller/src/utils/retry.ts b/tests/cases/typescript/context-utility-collapses-under-its-caller/src/utils/retry.ts new file mode 100644 index 000000000..b2f73adc2 --- /dev/null +++ b/tests/cases/typescript/context-utility-collapses-under-its-caller/src/utils/retry.ts @@ -0,0 +1,16 @@ +export async function withRetry(fn: () => Promise, times = 3): Promise { + let last: unknown; + for (let attempt = 0; attempt < times; attempt++) { + try { + return await fn(); + } catch (e) { + last = e; + await backoff(attempt); + } + } + throw last; +} + +export function backoff(attempt: number): Promise { + return new Promise((resolve) => setTimeout(resolve, 100 * 2 ** attempt)); +} diff --git a/tests/cases/typescript/context-utility-collapses-under-its-caller/src/utils/serialization.ts b/tests/cases/typescript/context-utility-collapses-under-its-caller/src/utils/serialization.ts new file mode 100644 index 000000000..b16d89543 --- /dev/null +++ b/tests/cases/typescript/context-utility-collapses-under-its-caller/src/utils/serialization.ts @@ -0,0 +1,7 @@ +export function toJson(obj: object): string { + return JSON.stringify(toPlain(obj)); +} + +export function toPlain(obj: object): Record { + return Object.fromEntries(Object.entries(obj).filter(([k]) => !k.startsWith("_"))); +} diff --git a/tests/cases/typescript/context-utility-collapses-under-its-caller/src/utils/strings.ts b/tests/cases/typescript/context-utility-collapses-under-its-caller/src/utils/strings.ts new file mode 100644 index 000000000..69bd3064a --- /dev/null +++ b/tests/cases/typescript/context-utility-collapses-under-its-caller/src/utils/strings.ts @@ -0,0 +1,11 @@ +export function normalizeId(value: string): string { + return value.trim().toLowerCase(); +} + +export function truncate(text: string, limit = 80): string { + return text.length <= limit ? text : text.slice(0, limit - 1) + "..."; +} + +export function formatMoney(cents: number): string { + return (cents / 100).toFixed(2); +}