Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions graph-ui/src/App.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/* @vitest-environment jsdom */
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { App } from "./App";
import { messages } from "./lib/i18n";

vi.mock("./components/GraphTab", () => ({ GraphTab: () => null }));
vi.mock("./components/StatsTab", () => ({ StatsTab: () => null }));
vi.mock("./components/ControlTab", () => ({ ControlTab: () => null }));
vi.mock("./lib/i18n", async (importOriginal) => {
const actual = await importOriginal<typeof import("./lib/i18n")>();
return { ...actual, useUiMessages: () => messages.en };
});

describe("App", () => {
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
window.history.replaceState(null, "", "/");
});

it("shows the serving binary version", async () => {
vi.stubGlobal("fetch", vi.fn(async () =>
new Response(JSON.stringify({ lang: "en", version: "0.10.8" }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
));

render(<App />);

expect(await screen.findByText("v0.10.8")).toBeVisible();
});

it("hides the version when the config has no string version", async () => {
const fetchMock = vi.fn(async () =>
new Response(JSON.stringify({ lang: "en", version: 108 }), { status: 200 }),
);
vi.stubGlobal("fetch", fetchMock);

render(<App />);

await waitFor(() => expect(fetchMock).toHaveBeenCalledWith("/api/ui-config"));
expect(screen.queryByTitle("Server version")).not.toBeInTheDocument();
});

it("hides the version when the config request fails", async () => {
const fetchMock = vi.fn(async () => {
throw new Error("offline");
});
vi.stubGlobal("fetch", fetchMock);

render(<App />);

await waitFor(() => expect(fetchMock).toHaveBeenCalledWith("/api/ui-config"));
expect(screen.queryByTitle("Server version")).not.toBeInTheDocument();
});
});
24 changes: 24 additions & 0 deletions graph-ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,24 @@ function routeUrl(tab: TabId, project: string | null): string {
export function App() {
const t = useUiMessages();
const [route, setRoute] = useState<RouteState>(readRoute);
const [version, setVersion] = useState<string | null>(null);
const { tab: activeTab, project: selectedProject } = route;

useEffect(() => {
let cancelled = false;
void fetch("/api/ui-config")
.then((response) => (response.ok ? response.json() : null))
.then((config) => {
if (!cancelled && typeof config?.version === "string" && config.version) {
setVersion(config.version);
}
})
.catch(() => {});
return () => {
cancelled = true;
};
}, []);

/* Normalize the URL on first load so it always carries the current route. */
useEffect(() => {
const initial = readRoute();
Expand Down Expand Up @@ -73,6 +89,14 @@ export function App() {
<span className="text-[13px] font-semibold text-foreground/90 tracking-tight">
Codebase Memory
</span>
{version && (
<span
className="translate-y-px text-[10px] font-mono text-foreground/30"
title="Server version"
>
{version.startsWith("v") ? version : `v${version}`}
</span>
)}
</div>

{/* Tabs inline in header */}
Expand Down
64 changes: 55 additions & 9 deletions internal/cbm/cbm.c
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
/* Full declaration set for the same CBMArena, and it must precede cbm.h:
* internal/cbm/arena.h declares a subset and the two share the CBM_ARENA_H
* guard, so whichever is included first is the one this file sees. */
#include "foundation/arena.h" // cbm_arena_init_sized
#include "cbm.h"
#include "arena.h" // CBMArena, cbm_arena_init/alloc/strdup/destroy
#include "helpers.h"
Expand Down Expand Up @@ -817,9 +821,13 @@ static void cbm_error_regions_push(cbm_error_regions_t *acc, TSNode n) {
* untouched.
*
* #1746: the Dockerfile grammar places that zero-width missing newline before
* trailing horizontal whitespace rather than at raw EOF. Preserve the broad
* exact-EOF rule above; only extend it past spaces/tabs when the missing token
* is specifically a newline. */
* trailing whitespace rather than at raw EOF. Preserve the broad exact-EOF
* rule above; only extend it past blanks when the missing token is specifically
* a newline. */
static bool cbm_is_blank_not_newline(char c) {
return c == ' ' || c == '\t' || c == '\v' || c == '\f' || c == '\r';
}

static bool cbm_is_eof_terminator_miss(TSNode n, const char *source, int source_len) {
if (!ts_node_is_missing(n) || source_len < 0) {
return false;
Expand All @@ -836,7 +844,7 @@ static bool cbm_is_eof_terminator_miss(TSNode n, const char *source, int source_
return false;
}
for (uint32_t i = end; i < (uint32_t)source_len; i++) {
if (source[i] != ' ' && source[i] != '\t') {
if (!cbm_is_blank_not_newline(source[i])) {
return false;
}
}
Expand Down Expand Up @@ -1186,11 +1194,27 @@ CBMFileResult *cbm_extract_file(const char *source, int source_len, CBMLanguage
return r;
}

CBMFileResult *cbm_extract_file_ex(const char *source, int source_len, CBMLanguage language,
const char *project, const char *rel_path,
int64_t timeout_micros, const char **extra_defines,
const char **include_paths, const CBMMacroTable *macro_table,
const CBMReturnTypeTable *return_type_table) {
/* Initial block for the per-file traversal scratch arena, chosen by measuring
* arena_grow on a 14k-file TypeScript tree: it fires on one file in 12,000 at
* both this size and at 1 MB, and on most files at 256 KB, where the two
* channel walks alone are exactly 262144 bytes. 512 KB therefore buys the same
* growth behaviour as 1 MB for half the resident block per worker. It is also
* exactly MI_LARGE_MAX_OBJ_SIZE in the vendored mimalloc
* (vendored/mimalloc/include/mimalloc/types.h:426, MI_LARGE_PAGE_SIZE/8 with
* MI_ENABLE_LARGE_PAGES defaulting to 1 at :115 and not overridden here), so
* the block is still bin-allocated from a large page. Growth is not free at
* this size for the same reason: arena_grow doubles to 1 MiB, which is above
* that bound and so a singleton OS allocation. One file in twelve thousand
* pays it, which is why the cost is accepted. */
enum { CBM_EXTRACT_SCRATCH_BLOCK = CBM_SZ_512 * CBM_SZ_1K };

static CBMFileResult *extract_file_ex_body(const char *source, int source_len, CBMLanguage language,
const char *project, const char *rel_path,
int64_t timeout_micros, const char **extra_defines,
const char **include_paths,
const CBMMacroTable *macro_table,
const CBMReturnTypeTable *return_type_table,
CBMArena *scratch) {
// Allocate result on heap (arena inside for all string data)
enum { SINGLE = 1 };
CBMFileResult *result = (CBMFileResult *)calloc(SINGLE, sizeof(CBMFileResult));
Expand Down Expand Up @@ -1301,6 +1325,7 @@ CBMFileResult *cbm_extract_file_ex(const char *source, int source_len, CBMLangua
// Build extraction context
CBMExtractCtx ctx = {
.arena = a,
.scratch = scratch,
.result = result,
.source = source,
.source_len = source_len,
Expand Down Expand Up @@ -1429,6 +1454,7 @@ CBMFileResult *cbm_extract_file_ex(const char *source, int source_len, CBMLangua
// Build context for expanded source — extract only calls via unified extractor
CBMExtractCtx pp_ctx = {
.arena = a,
.scratch = scratch,
.result = result,
.source = expanded,
.source_len = expanded_len,
Expand Down Expand Up @@ -1665,6 +1691,26 @@ CBMFileResult *cbm_extract_file_ex(const char *source, int source_len, CBMLangua
return result;
}

/* Public entry. Owns the traversal scratch arena for the whole of one file's
* extraction: created here, handed to the body as ctx->scratch, destroyed on
* the way out. The body has seven early returns, so bracketing it in a wrapper
* is what keeps that to one create and one destroy. If the arena cannot be
* created, the body is handed NULL and the traversal stacks fall back to the
* result arena, which is what shipped before #1997. */
CBMFileResult *cbm_extract_file_ex(const char *source, int source_len, CBMLanguage language,
const char *project, const char *rel_path,
int64_t timeout_micros, const char **extra_defines,
const char **include_paths, const CBMMacroTable *macro_table,
const CBMReturnTypeTable *return_type_table) {
CBMArena scratch;
cbm_arena_init_sized(&scratch, CBM_EXTRACT_SCRATCH_BLOCK);
CBMFileResult *result = extract_file_ex_body(
source, source_len, language, project, rel_path, timeout_micros, extra_defines,
include_paths, macro_table, return_type_table, scratch.nblocks > 0 ? &scratch : NULL);
cbm_arena_destroy(&scratch);
return result;
}

void cbm_free_result(CBMFileResult *result) {
if (!result) {
return;
Expand Down
11 changes: 11 additions & 0 deletions internal/cbm/cbm.h
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,10 @@ typedef struct {
// pass_lsp_cross.c. Default false.
bool requires_lsp_resolution; // synthetic semantic candidate (for example an implicit
// C++ operator). Never fall back to textual resolution.
bool callee_is_locally_bound; // bare call foo() whose callee identifier is bound as a
// parameter of an enclosing function, so it cannot be the
// module-level foo. Python only today. Read by the
// weak-local-binding guard. Default false.
} CBMCall;

typedef struct {
Expand Down Expand Up @@ -589,6 +593,13 @@ typedef struct {

typedef struct {
CBMArena *arena;
/* Scratch for AST traversal, owned by the cbm_extract_file_ex call that
* built this context and destroyed when it returns. Nothing a
* CBMFileResult points at may be allocated here: `arena` is the result's
* own, and it outlives extraction by the whole pipeline (#1997). NULL in a
* context built without one, in which case the stacks fall back to
* `arena`. */
CBMArena *scratch;
CBMFileResult *result;
const char *source;
int source_len;
Expand Down
42 changes: 42 additions & 0 deletions internal/cbm/extract_calls.c
Original file line number Diff line number Diff line change
Expand Up @@ -2973,6 +2973,41 @@ static bool python_receiver_is_exempt(CBMExtractCtx *ctx, TSNode receiver) {
return false;
}

/* Name bound by one Python parameter node, or NULL when the shape binds none.
* Covers every binding form a `parameters` / `lambda_parameters` list produces:
* a bare `identifier`, the `name` field of default/typed parameters, and the
* identifier under a `*args` / `**kwargs` splat. A shape with no identifier (the
* bare `*` keyword separator) yields NULL and simply matches nothing. */
/* True when the callee of a BARE Python call `foo()` is bound as a parameter of
* an enclosing function or lambda -- the bare-call counterpart of
* python_receiver_is_exempt above.
*
* A parameter binding shadows any module-level `foo` for the whole body, so
* resolving such a call to a project Function/Method by short name alone
* fabricates the edge BY CONSTRUCTION: `def _run_with_heavy_slot(run): run()`
* must not bind an unrelated `SatoriLive.run`. Unlike a receiver type this is
* decidable from the AST outright, with no flow analysis and no list of
* "generic-looking" callee names -- Python forbids `global` on a parameter, and
* a parameter is in scope for the entire body regardless of position.
*
* The answer is CARRIED BY THE WALK rather than recomputed here. The unified
* walk binds a def's or lambda's parameters when it opens that scope and
* unwinds them when it closes it, so this is an O(1) map lookup. Deciding it
* by ascending the tree instead -- with ts_node_parent() or with a copied walk
* cursor -- costs O(depth) per call, and since every level of f(f(f(...))) is
* itself a bare call, that is quadratic across the file: the 30,000-deep
* fixture in tests/test_stack_overflow.c turned it into a hang rather than a
* slowdown. An O(1) lookup needs no hop cap, so unlike a capped walk this can
* no longer fail open on deep-but-ordinary code.
*
* It still answers false if the walk's own tracking hit an allocation failure,
* which costs a suppression and never a true edge. */
static bool python_callee_is_bound_parameter(CBMExtractCtx *ctx, WalkState *state,
TSNode callee_ident) {
const char *callee_name = cbm_node_text(ctx->arena, callee_ident, ctx->source);
return cbm_walk_python_param_is_bound(state, callee_name);
}

static bool is_objectscript_language(CBMLanguage language) {
return language == CBM_LANG_OBJECTSCRIPT_UDL || language == CBM_LANG_OBJECTSCRIPT_ROUTINE;
}
Expand Down Expand Up @@ -3597,11 +3632,18 @@ CBMInvocationDescriptor handle_calls(CBMExtractCtx *ctx, TSNode node, const CBML
// (`accelerator.print()` must not bind MockAccelerator.print).
// Imported receivers stay unflagged: module.function() is Python's
// canonical cross-file call and the import map resolves it.
// A BARE Python call foo() whose callee is bound as a parameter of an
// enclosing scope cannot be the module-level foo, so short-name
// resolution would fabricate the edge (`def f(run): run()` must not
// bind SatoriLive.run). Distinct from is_method: there is no receiver
// here, so the weak-member guard cannot see this class at all.
if (ctx->language == CBM_LANG_PYTHON && strcmp(ts_node_type(node), "call") == 0) {
TSNode fn = ts_node_child_by_field_name(node, TS_FIELD("function"));
if (!ts_node_is_null(fn) && strcmp(ts_node_type(fn), "attribute") == 0) {
TSNode obj = ts_node_child_by_field_name(fn, TS_FIELD("object"));
call.is_method = !python_receiver_is_exempt(ctx, obj);
} else if (!ts_node_is_null(fn) && strcmp(ts_node_type(fn), "identifier") == 0) {
call.callee_is_locally_bound = python_callee_is_bound_parameter(ctx, state, fn);
}
}
// TS/JS/TSX receiver-aware guard (#592/#606 direction; same intent
Expand Down
Loading
Loading