Skip to content

Commit 7470006

Browse files
authored
Merge branch 'main' into fix/return-star-respects-with-scope
2 parents 426e415 + 951e02e commit 7470006

30 files changed

Lines changed: 1634 additions & 307 deletions

graph-ui/src/App.test.tsx

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/* @vitest-environment jsdom */
2+
import "@testing-library/jest-dom/vitest";
3+
import { cleanup, render, screen, waitFor } from "@testing-library/react";
4+
import { afterEach, describe, expect, it, vi } from "vitest";
5+
import { App } from "./App";
6+
import { messages } from "./lib/i18n";
7+
8+
vi.mock("./components/GraphTab", () => ({ GraphTab: () => null }));
9+
vi.mock("./components/StatsTab", () => ({ StatsTab: () => null }));
10+
vi.mock("./components/ControlTab", () => ({ ControlTab: () => null }));
11+
vi.mock("./lib/i18n", async (importOriginal) => {
12+
const actual = await importOriginal<typeof import("./lib/i18n")>();
13+
return { ...actual, useUiMessages: () => messages.en };
14+
});
15+
16+
describe("App", () => {
17+
afterEach(() => {
18+
cleanup();
19+
vi.unstubAllGlobals();
20+
window.history.replaceState(null, "", "/");
21+
});
22+
23+
it("shows the serving binary version", async () => {
24+
vi.stubGlobal("fetch", vi.fn(async () =>
25+
new Response(JSON.stringify({ lang: "en", version: "0.10.8" }), {
26+
status: 200,
27+
headers: { "Content-Type": "application/json" },
28+
}),
29+
));
30+
31+
render(<App />);
32+
33+
expect(await screen.findByText("v0.10.8")).toBeVisible();
34+
});
35+
36+
it("hides the version when the config has no string version", async () => {
37+
const fetchMock = vi.fn(async () =>
38+
new Response(JSON.stringify({ lang: "en", version: 108 }), { status: 200 }),
39+
);
40+
vi.stubGlobal("fetch", fetchMock);
41+
42+
render(<App />);
43+
44+
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith("/api/ui-config"));
45+
expect(screen.queryByTitle("Server version")).not.toBeInTheDocument();
46+
});
47+
48+
it("hides the version when the config request fails", async () => {
49+
const fetchMock = vi.fn(async () => {
50+
throw new Error("offline");
51+
});
52+
vi.stubGlobal("fetch", fetchMock);
53+
54+
render(<App />);
55+
56+
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith("/api/ui-config"));
57+
expect(screen.queryByTitle("Server version")).not.toBeInTheDocument();
58+
});
59+
});

graph-ui/src/App.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,24 @@ function routeUrl(tab: TabId, project: string | null): string {
3333
export function App() {
3434
const t = useUiMessages();
3535
const [route, setRoute] = useState<RouteState>(readRoute);
36+
const [version, setVersion] = useState<string | null>(null);
3637
const { tab: activeTab, project: selectedProject } = route;
3738

39+
useEffect(() => {
40+
let cancelled = false;
41+
void fetch("/api/ui-config")
42+
.then((response) => (response.ok ? response.json() : null))
43+
.then((config) => {
44+
if (!cancelled && typeof config?.version === "string" && config.version) {
45+
setVersion(config.version);
46+
}
47+
})
48+
.catch(() => {});
49+
return () => {
50+
cancelled = true;
51+
};
52+
}, []);
53+
3854
/* Normalize the URL on first load so it always carries the current route. */
3955
useEffect(() => {
4056
const initial = readRoute();
@@ -73,6 +89,14 @@ export function App() {
7389
<span className="text-[13px] font-semibold text-foreground/90 tracking-tight">
7490
Codebase Memory
7591
</span>
92+
{version && (
93+
<span
94+
className="translate-y-px text-[10px] font-mono text-foreground/30"
95+
title="Server version"
96+
>
97+
{version.startsWith("v") ? version : `v${version}`}
98+
</span>
99+
)}
76100
</div>
77101

78102
{/* Tabs inline in header */}

internal/cbm/cbm.c

Lines changed: 55 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
/* Full declaration set for the same CBMArena, and it must precede cbm.h:
2+
* internal/cbm/arena.h declares a subset and the two share the CBM_ARENA_H
3+
* guard, so whichever is included first is the one this file sees. */
4+
#include "foundation/arena.h" // cbm_arena_init_sized
15
#include "cbm.h"
26
#include "arena.h" // CBMArena, cbm_arena_init/alloc/strdup/destroy
37
#include "helpers.h"
@@ -817,9 +821,13 @@ static void cbm_error_regions_push(cbm_error_regions_t *acc, TSNode n) {
817821
* untouched.
818822
*
819823
* #1746: the Dockerfile grammar places that zero-width missing newline before
820-
* trailing horizontal whitespace rather than at raw EOF. Preserve the broad
821-
* exact-EOF rule above; only extend it past spaces/tabs when the missing token
822-
* is specifically a newline. */
824+
* trailing whitespace rather than at raw EOF. Preserve the broad exact-EOF
825+
* rule above; only extend it past blanks when the missing token is specifically
826+
* a newline. */
827+
static bool cbm_is_blank_not_newline(char c) {
828+
return c == ' ' || c == '\t' || c == '\v' || c == '\f' || c == '\r';
829+
}
830+
823831
static bool cbm_is_eof_terminator_miss(TSNode n, const char *source, int source_len) {
824832
if (!ts_node_is_missing(n) || source_len < 0) {
825833
return false;
@@ -836,7 +844,7 @@ static bool cbm_is_eof_terminator_miss(TSNode n, const char *source, int source_
836844
return false;
837845
}
838846
for (uint32_t i = end; i < (uint32_t)source_len; i++) {
839-
if (source[i] != ' ' && source[i] != '\t') {
847+
if (!cbm_is_blank_not_newline(source[i])) {
840848
return false;
841849
}
842850
}
@@ -1186,11 +1194,27 @@ CBMFileResult *cbm_extract_file(const char *source, int source_len, CBMLanguage
11861194
return r;
11871195
}
11881196

1189-
CBMFileResult *cbm_extract_file_ex(const char *source, int source_len, CBMLanguage language,
1190-
const char *project, const char *rel_path,
1191-
int64_t timeout_micros, const char **extra_defines,
1192-
const char **include_paths, const CBMMacroTable *macro_table,
1193-
const CBMReturnTypeTable *return_type_table) {
1197+
/* Initial block for the per-file traversal scratch arena, chosen by measuring
1198+
* arena_grow on a 14k-file TypeScript tree: it fires on one file in 12,000 at
1199+
* both this size and at 1 MB, and on most files at 256 KB, where the two
1200+
* channel walks alone are exactly 262144 bytes. 512 KB therefore buys the same
1201+
* growth behaviour as 1 MB for half the resident block per worker. It is also
1202+
* exactly MI_LARGE_MAX_OBJ_SIZE in the vendored mimalloc
1203+
* (vendored/mimalloc/include/mimalloc/types.h:426, MI_LARGE_PAGE_SIZE/8 with
1204+
* MI_ENABLE_LARGE_PAGES defaulting to 1 at :115 and not overridden here), so
1205+
* the block is still bin-allocated from a large page. Growth is not free at
1206+
* this size for the same reason: arena_grow doubles to 1 MiB, which is above
1207+
* that bound and so a singleton OS allocation. One file in twelve thousand
1208+
* pays it, which is why the cost is accepted. */
1209+
enum { CBM_EXTRACT_SCRATCH_BLOCK = CBM_SZ_512 * CBM_SZ_1K };
1210+
1211+
static CBMFileResult *extract_file_ex_body(const char *source, int source_len, CBMLanguage language,
1212+
const char *project, const char *rel_path,
1213+
int64_t timeout_micros, const char **extra_defines,
1214+
const char **include_paths,
1215+
const CBMMacroTable *macro_table,
1216+
const CBMReturnTypeTable *return_type_table,
1217+
CBMArena *scratch) {
11941218
// Allocate result on heap (arena inside for all string data)
11951219
enum { SINGLE = 1 };
11961220
CBMFileResult *result = (CBMFileResult *)calloc(SINGLE, sizeof(CBMFileResult));
@@ -1301,6 +1325,7 @@ CBMFileResult *cbm_extract_file_ex(const char *source, int source_len, CBMLangua
13011325
// Build extraction context
13021326
CBMExtractCtx ctx = {
13031327
.arena = a,
1328+
.scratch = scratch,
13041329
.result = result,
13051330
.source = source,
13061331
.source_len = source_len,
@@ -1429,6 +1454,7 @@ CBMFileResult *cbm_extract_file_ex(const char *source, int source_len, CBMLangua
14291454
// Build context for expanded source — extract only calls via unified extractor
14301455
CBMExtractCtx pp_ctx = {
14311456
.arena = a,
1457+
.scratch = scratch,
14321458
.result = result,
14331459
.source = expanded,
14341460
.source_len = expanded_len,
@@ -1665,6 +1691,26 @@ CBMFileResult *cbm_extract_file_ex(const char *source, int source_len, CBMLangua
16651691
return result;
16661692
}
16671693

1694+
/* Public entry. Owns the traversal scratch arena for the whole of one file's
1695+
* extraction: created here, handed to the body as ctx->scratch, destroyed on
1696+
* the way out. The body has seven early returns, so bracketing it in a wrapper
1697+
* is what keeps that to one create and one destroy. If the arena cannot be
1698+
* created, the body is handed NULL and the traversal stacks fall back to the
1699+
* result arena, which is what shipped before #1997. */
1700+
CBMFileResult *cbm_extract_file_ex(const char *source, int source_len, CBMLanguage language,
1701+
const char *project, const char *rel_path,
1702+
int64_t timeout_micros, const char **extra_defines,
1703+
const char **include_paths, const CBMMacroTable *macro_table,
1704+
const CBMReturnTypeTable *return_type_table) {
1705+
CBMArena scratch;
1706+
cbm_arena_init_sized(&scratch, CBM_EXTRACT_SCRATCH_BLOCK);
1707+
CBMFileResult *result = extract_file_ex_body(
1708+
source, source_len, language, project, rel_path, timeout_micros, extra_defines,
1709+
include_paths, macro_table, return_type_table, scratch.nblocks > 0 ? &scratch : NULL);
1710+
cbm_arena_destroy(&scratch);
1711+
return result;
1712+
}
1713+
16681714
void cbm_free_result(CBMFileResult *result) {
16691715
if (!result) {
16701716
return;

internal/cbm/cbm.h

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,10 @@ typedef struct {
269269
// pass_lsp_cross.c. Default false.
270270
bool requires_lsp_resolution; // synthetic semantic candidate (for example an implicit
271271
// C++ operator). Never fall back to textual resolution.
272+
bool callee_is_locally_bound; // bare call foo() whose callee identifier is bound as a
273+
// parameter of an enclosing function, so it cannot be the
274+
// module-level foo. Python only today. Read by the
275+
// weak-local-binding guard. Default false.
272276
} CBMCall;
273277

274278
typedef struct {
@@ -589,6 +593,13 @@ typedef struct {
589593

590594
typedef struct {
591595
CBMArena *arena;
596+
/* Scratch for AST traversal, owned by the cbm_extract_file_ex call that
597+
* built this context and destroyed when it returns. Nothing a
598+
* CBMFileResult points at may be allocated here: `arena` is the result's
599+
* own, and it outlives extraction by the whole pipeline (#1997). NULL in a
600+
* context built without one, in which case the stacks fall back to
601+
* `arena`. */
602+
CBMArena *scratch;
592603
CBMFileResult *result;
593604
const char *source;
594605
int source_len;

internal/cbm/extract_calls.c

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,8 @@ static const char *lookup_url_builder(const CBMExtractCtx *ctx, const char *name
6161
static int is_string_like(const char *kind) {
6262
return (strcmp(kind, "string") == 0 || strcmp(kind, "string_literal") == 0 ||
6363
strcmp(kind, "interpreted_string_literal") == 0 ||
64-
strcmp(kind, "raw_string_literal") == 0 || strcmp(kind, "string_content") == 0);
64+
strcmp(kind, "raw_string_literal") == 0 || strcmp(kind, "string_content") == 0 ||
65+
strcmp(kind, "line_string_literal") == 0);
6566
}
6667

6768
/* Strip surrounding quotes from a string, return arena-allocated copy */
@@ -2286,6 +2287,18 @@ static const char *extract_url_or_topic_arg(CBMExtractCtx *ctx, TSNode args) {
22862287
if (strcmp(ts_node_type(arg), "argument") == 0 && ts_node_named_child_count(arg) > 0) {
22872288
arg = ts_node_named_child(arg, 0);
22882289
}
2290+
/* Swift wraps each argument in a value_argument that may lead with its
2291+
* label, so `data(from: url)` would otherwise yield the label `from`
2292+
* rather than the value. Step past a leading value_argument_label. */
2293+
if (strcmp(ts_node_type(arg), "value_argument") == 0 &&
2294+
ts_node_named_child_count(arg) > 0) {
2295+
TSNode val = ts_node_named_child(arg, 0);
2296+
if (strcmp(ts_node_type(val), "value_argument_label") == 0 &&
2297+
ts_node_named_child_count(arg) > 1) {
2298+
val = ts_node_named_child(arg, 1);
2299+
}
2300+
arg = val;
2301+
}
22892302
const char *ak = ts_node_type(arg);
22902303

22912304
if (strcmp(ak, "keyword_argument") == 0 || strcmp(ak, "pair") == 0) {
@@ -2973,6 +2986,41 @@ static bool python_receiver_is_exempt(CBMExtractCtx *ctx, TSNode receiver) {
29732986
return false;
29742987
}
29752988

2989+
/* Name bound by one Python parameter node, or NULL when the shape binds none.
2990+
* Covers every binding form a `parameters` / `lambda_parameters` list produces:
2991+
* a bare `identifier`, the `name` field of default/typed parameters, and the
2992+
* identifier under a `*args` / `**kwargs` splat. A shape with no identifier (the
2993+
* bare `*` keyword separator) yields NULL and simply matches nothing. */
2994+
/* True when the callee of a BARE Python call `foo()` is bound as a parameter of
2995+
* an enclosing function or lambda -- the bare-call counterpart of
2996+
* python_receiver_is_exempt above.
2997+
*
2998+
* A parameter binding shadows any module-level `foo` for the whole body, so
2999+
* resolving such a call to a project Function/Method by short name alone
3000+
* fabricates the edge BY CONSTRUCTION: `def _run_with_heavy_slot(run): run()`
3001+
* must not bind an unrelated `SatoriLive.run`. Unlike a receiver type this is
3002+
* decidable from the AST outright, with no flow analysis and no list of
3003+
* "generic-looking" callee names -- Python forbids `global` on a parameter, and
3004+
* a parameter is in scope for the entire body regardless of position.
3005+
*
3006+
* The answer is CARRIED BY THE WALK rather than recomputed here. The unified
3007+
* walk binds a def's or lambda's parameters when it opens that scope and
3008+
* unwinds them when it closes it, so this is an O(1) map lookup. Deciding it
3009+
* by ascending the tree instead -- with ts_node_parent() or with a copied walk
3010+
* cursor -- costs O(depth) per call, and since every level of f(f(f(...))) is
3011+
* itself a bare call, that is quadratic across the file: the 30,000-deep
3012+
* fixture in tests/test_stack_overflow.c turned it into a hang rather than a
3013+
* slowdown. An O(1) lookup needs no hop cap, so unlike a capped walk this can
3014+
* no longer fail open on deep-but-ordinary code.
3015+
*
3016+
* It still answers false if the walk's own tracking hit an allocation failure,
3017+
* which costs a suppression and never a true edge. */
3018+
static bool python_callee_is_bound_parameter(CBMExtractCtx *ctx, WalkState *state,
3019+
TSNode callee_ident) {
3020+
const char *callee_name = cbm_node_text(ctx->arena, callee_ident, ctx->source);
3021+
return cbm_walk_python_param_is_bound(state, callee_name);
3022+
}
3023+
29763024
static bool is_objectscript_language(CBMLanguage language) {
29773025
return language == CBM_LANG_OBJECTSCRIPT_UDL || language == CBM_LANG_OBJECTSCRIPT_ROUTINE;
29783026
}
@@ -3045,6 +3093,19 @@ static TSNode objectscript_call_args(TSNode node) {
30453093
: cbm_find_child_by_kind(macro_function, "method_args");
30463094
}
30473095

3096+
/* Swift models a call as a target expression plus a call_suffix, and its grammar
3097+
* declares no "arguments" field at all, so the generic field lookup finds
3098+
* nothing for every Swift call. Reach the argument list through the suffix
3099+
* instead. A trailing closure has a call_suffix with no value_arguments, which
3100+
* returns a null node and leaves the call without a string argument, as before. */
3101+
static TSNode swift_call_args(TSNode node) {
3102+
TSNode suffix = cbm_find_child_by_kind(node, "call_suffix");
3103+
if (ts_node_is_null(suffix)) {
3104+
return (TSNode){0};
3105+
}
3106+
return cbm_find_child_by_kind(suffix, "value_arguments");
3107+
}
3108+
30483109
static bool node_has_token(TSNode node, const char *token) {
30493110
uint32_t count = ts_node_child_count(node);
30503111
for (uint32_t i = 0; i < count; i++) {
@@ -3597,11 +3658,18 @@ CBMInvocationDescriptor handle_calls(CBMExtractCtx *ctx, TSNode node, const CBML
35973658
// (`accelerator.print()` must not bind MockAccelerator.print).
35983659
// Imported receivers stay unflagged: module.function() is Python's
35993660
// canonical cross-file call and the import map resolves it.
3661+
// A BARE Python call foo() whose callee is bound as a parameter of an
3662+
// enclosing scope cannot be the module-level foo, so short-name
3663+
// resolution would fabricate the edge (`def f(run): run()` must not
3664+
// bind SatoriLive.run). Distinct from is_method: there is no receiver
3665+
// here, so the weak-member guard cannot see this class at all.
36003666
if (ctx->language == CBM_LANG_PYTHON && strcmp(ts_node_type(node), "call") == 0) {
36013667
TSNode fn = ts_node_child_by_field_name(node, TS_FIELD("function"));
36023668
if (!ts_node_is_null(fn) && strcmp(ts_node_type(fn), "attribute") == 0) {
36033669
TSNode obj = ts_node_child_by_field_name(fn, TS_FIELD("object"));
36043670
call.is_method = !python_receiver_is_exempt(ctx, obj);
3671+
} else if (!ts_node_is_null(fn) && strcmp(ts_node_type(fn), "identifier") == 0) {
3672+
call.callee_is_locally_bound = python_callee_is_bound_parameter(ctx, state, fn);
36053673
}
36063674
}
36073675
// TS/JS/TSX receiver-aware guard (#592/#606 direction; same intent
@@ -3634,6 +3702,10 @@ CBMInvocationDescriptor handle_calls(CBMExtractCtx *ctx, TSNode node, const CBML
36343702
if (ts_node_is_null(args) && is_objectscript_language(ctx->language)) {
36353703
args = objectscript_call_args(node);
36363704
}
3705+
// Swift has no "arguments" field either; its args hang off call_suffix.
3706+
if (ts_node_is_null(args) && ctx->language == CBM_LANG_SWIFT) {
3707+
args = swift_call_args(node);
3708+
}
36373709
if (!ts_node_is_null(args)) {
36383710
call.first_string_arg = extract_url_or_topic_arg(ctx, args);
36393711
/* #952: routes registered inside Laravel `prefix()->group()`

0 commit comments

Comments
 (0)