feat(native): retain LLDB source coordinates and verify Pascal call entries

Preserve partial line-table attribution for native locations and historical
evidence compatibility. Verify owned Free Pascal call entries through CLI/MCP.

Refs #2382, #2372, #2397

Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
Matheus Breguêz
2026-10-12 18:30:30 -03:00
committed by morluto
parent 0bc422d7be
commit 8b0e330c25
15 changed files with 371 additions and 2 deletions
+15
View File
@@ -244,6 +244,20 @@ def _hex(value):
return "0x%x" % value
def _source_location(address):
"""Preserve available line-entry facets without acquiring source files."""
entry = address.GetLineEntry()
if not entry.IsValid():
return None
file_spec = entry.GetFileSpec()
file_path = str(file_spec) if file_spec.IsValid() else None
line = entry.GetLine() or None
column = entry.GetColumn() or None
if not file_path and line is None and column is None:
return None
return {"file_path": file_path or None, "line": line, "column": column}
def _location(address, target):
"""Load and file address, module and symbol of one code address."""
module = address.GetModule()
@@ -257,6 +271,7 @@ def _location(address, target):
"module": module.GetFileSpec().GetFilename() if module.IsValid() else None,
"module_path": str(module.GetFileSpec()) if module.IsValid() else None,
"symbol": symbol.GetName() if symbol.IsValid() else None,
"source_location": _source_location(address),
}
return location
+45
View File
@@ -22,6 +22,45 @@ spec.loader.exec_module(tracer)
class NativeTraceBudgetTests(unittest.TestCase):
def test_source_coordinates_preserve_partial_metadata_without_reading_source(self):
def address(valid=True, file_valid=True, line=23, column=7):
class SourceFileSpec:
def IsValid(self):
return file_valid
def __str__(self):
return "/missing/é space/fixture.pas"
file_spec = SourceFileSpec()
entry = types.SimpleNamespace(
IsValid=lambda: valid,
GetFileSpec=lambda: file_spec,
GetLine=lambda: line,
GetColumn=lambda: column,
)
return types.SimpleNamespace(GetLineEntry=lambda: entry)
self.assertEqual(
tracer._source_location(address()),
{"file_path": "/missing/é space/fixture.pas", "line": 23, "column": 7},
)
self.assertEqual(
tracer._source_location(address(column=0)),
{"file_path": "/missing/é space/fixture.pas", "line": 23, "column": None},
)
self.assertEqual(
tracer._source_location(address(file_valid=False)),
{"file_path": None, "line": 23, "column": 7},
)
self.assertEqual(
tracer._source_location(address(line=0)),
{"file_path": "/missing/é space/fixture.pas", "line": None, "column": 7},
)
self.assertIsNone(tracer._source_location(address(valid=False)))
self.assertIsNone(
tracer._source_location(address(file_valid=False, line=0, column=0))
)
def test_journal_checks_exact_byte_budget_before_writing(self):
record = {"kind": "other-stop", "reason": "signal"}
encoded_size = len((tracer.json.dumps(record) + "\n").encode("utf-8"))
@@ -154,6 +193,9 @@ class NativeTraceBudgetTests(unittest.TestCase):
return FileSpec()
class Address:
def GetLineEntry(self):
return types.SimpleNamespace(IsValid=lambda: False)
def GetModule(self):
return Module()
@@ -228,6 +270,9 @@ class NativeTraceBudgetTests(unittest.TestCase):
return FileSpec()
class Address:
def GetLineEntry(self):
return types.SimpleNamespace(IsValid=lambda: False)
def GetModule(self):
return Module()
+19
View File
@@ -426,6 +426,25 @@ arguments. The receiver class comes from LLDB's Objective-C runtime reader
without running target code; no expression is evaluated, and REA never
attaches to a process it did not launch.
Resolved breakpoint locations, entries and caller frames also retain
`source_location` from LLDB's line tables when available. Its `file_path`,
one-based `line` and one-based `column` are independently nullable. An invalid
line entry is null; historical captures may omit the field. This is debug metadata
attribution: REA does not read that source path or verify the source file bytes.
Absent coordinates do not imply that the executable has no source code.
Free Pascal programs with retained native symbols and usable DWARF can use this
same bounded call observation on macOS. Select an exact emitted symbol from the
binary rather than assuming a source procedure name. The owned Pascal fixture
uses an explicit C calling convention and a public symbol; its verification
covers call entries, raw integer registers and source coordinates, without
establishing typed Pascal locals or interactive stepping. After building REA,
run the real lane with an existing caller-selected compiler:
```sh
REA_VERIFY_FPC_COMMAND=/absolute/path/to/fpc npm run verify:native:pascal-calls
```
The run ends when the process exits, `max_events` entries are recorded,
`duration_ms` elapses, or the aggregate trace resource budget is exhausted.
Bounded termination reports partial coverage. REA checks process termination
@@ -0,0 +1,39 @@
# Implementation plan
## Boundary and contract
Extend nativeCodeLocationSchema in src/domain/native/nativeCallObservation.ts.
Reuse that shape for events, breakpoint locations, backtraces and retained journal
events. Keep the field optional for older evidence and nullable for current LLDB
observations with unavailable metadata. Each inner facet can be independently
unavailable; avoid dropping a valid line number because a file path is absent.
Read SBAddress.GetLineEntry once in the existing Python _location producer. A
valid SBFileSpec supplies its displayed path without filesystem access. Convert
zero line/column to null. An invalid entry returns null. Existing estimation and
serialization budget checks already traverse the location object recursively.
## Incremental work and validation
1. Define contracts and criteria before code; add failing producer tests.
2. Extend the canonical schema and _location producer. Update existing fake
address objects to implement the actual LLDB line-entry interface.
3. Extend provider/MCP boundary coverage and retained-journal coverage for the
additive field; preserve omission compatibility.
4. Extend the existing real macOS verifier with source-coordinate assertions
against its own Objective-C/C fixture built with debug information.
5. Update the native investigation guide; run Python tests, focused TypeScript
tests, repository checks and the real verifier if host authority permits.
## Effects and compatibility
The existing LLDB operation already owns its breakpoint callbacks and process
lifecycle. This adds a metadata read within that observation. It does not introduce
target execution or a new provider beyond existing behavior. No source file is
acquired from a path in debug information. Consumers of historical evidence retain
omission; consumers of new captures see either a partial/full object or null.
## References
- https://lldb.llvm.org/python_api/lldb.SBAddress.html
- https://lldb.llvm.org/python_api/lldb.SBLineEntry.html
@@ -0,0 +1,36 @@
# Native call source coordinates
Issue: https://github.com/morluto/rea/issues/2382
## Problem and outcome
The existing bounded macOS LLDB call observation retains module, symbol and
addresses, but loses compiler-provided source coordinates. Retain line-entry
metadata at the same producer boundary for breakpoint locations, call entries
and caller frames.
## Requirements
- Add optional nullable source_location to the canonical native code location.
A present object contains nullable file_path, line and column. Positive line
and column values are one-based. Provider zero values mean unavailable.
- Preserve partial facts from a valid line entry. Invalid entries or entries
without any usable facet produce null. Historical captures may omit the field.
- Read only SBAddress line metadata; do not resolve paths, read source contents,
evaluate expressions or change the existing launch, stop or cleanup policy.
- The field establishes LLDB line-table attribution, not verified source bytes.
- Existing trace and breakpoint byte budgets include the new representation.
## Acceptance
Producer tests cover valid, invalid and partial entries, Unicode paths and absent
coordinates. Existing evidence without the field remains accepted. CLI/MCP and
partial retained-journal paths preserve the canonical field. The real native-call
verifier checks source coordinates for its owned debug build, including caller
frames and breakpoint locations, and reports actual host/provider coverage.
## Out of scope
New debugger adapters, interactive stepping, source breakpoints, source-file
association verification, source reads, Pascal/Delphi compatibility claims and
Java/Android/CLR runtime support.
@@ -0,0 +1,9 @@
# Tasks
- [x] Define the single-feature scope, contract and implementation plan.
- [x] Reproduce lost source coordinates with producer regression tests.
- [x] Retain line-entry metadata in the producer and canonical schema.
- [x] Verify boundary/MCP propagation and historical omission.
- [x] Verify real CLI/MCP call coordinates on an owned Free Pascal 3.2.2 program.
- [x] Update documentation; pass Python, focused TypeScript, static and discovery checks.
- [ ] Complete the full native-call verifier after investigating #2397.
@@ -0,0 +1,22 @@
# Verification
Host: macOS ARM64. Compiler: caller-installed Free Pascal 3.2.2, Darwin AArch64.
Debugger: LLDB 2103.0.34.103 from Command Line Tools.
- Python producer/budget/output tests: 10 passed.
- Focused native provider, retained-journal and MCP tests: 18 passed.
- Real Pascal CLI and stdio MCP: three expected calls, integer register values
0/1/2, source path and positive line attribution, successful target exit.
- Repository static checks: six tasks and architecture guards passed.
- Test discovery: all 982 test files discovered exactly once.
The complete native-call verifier did not pass. One run reported exited instead
of event-limit for capped overlapping breakpoints. Another reported unavailable
launch start identity in a later capped scenario. Investigation:
https://github.com/morluto/rea/issues/2397. These results have not yet been isolated
on unchanged upstream. The source-coordinate PR remains draft.
The first real native scenario and CLI/MCP source-coordinate comparisons completed
before those broader failures. The Pascal lane verifies bounded native call
observation; it does not establish typed Pascal local values, source breakpoints,
interactive stepping, Delphi compatibility or a GDB provider.
+1
View File
@@ -246,6 +246,7 @@
"prepare": "node scripts/prepare.mjs",
"verify:asset-catalog": "npm run build:cached && node scripts/verify/apple/asset-catalog.mjs",
"verify:native-calls": "npm run build:cached && node scripts/verify-native-calls.mjs",
"verify:native:pascal-calls": "npm run build:cached && node scripts/verify-native-pascal-calls.mjs",
"verify:native-ui:permissions": "npm run build:cached && node scripts/verify-native-ui.mjs --allow-permission-denial",
"test:focused": "node scripts/run-development-tests.mjs focused",
"test:sqlite": "npm run test:prepare && npm run test:sqlite:run",
+18
View File
@@ -60,6 +60,10 @@ const stable = (result) =>
file_address: event.file_address,
selector: event.selector,
receiver_class: event.receiver_class,
source_location: event.source_location,
caller_source_locations: event.backtrace.map(
(frame) => frame.source_location,
),
counter:
event.symbol === GREET
? event.registers[3].value
@@ -95,6 +99,18 @@ const checkScenario = async (binary) => {
});
const greet = result.events.filter(({ symbol }) => symbol === GREET);
const add = result.events.filter(({ symbol }) => symbol === "rea_call_add");
for (const location of [
...result.events,
...result.events.flatMap((event) => event.backtrace),
...result.breakpoints.flatMap((breakpoint) => breakpoint.locations),
]) {
assert.equal(location.source_location?.file_path, SOURCE);
assert.ok(location.source_location.line > 0);
assert.ok(
location.source_location.column === null ||
location.source_location.column > 0,
);
}
assert.equal(greet.length, 3, JSON.stringify(result));
assert.equal(add.length, 3, JSON.stringify(result));
for (const [index, event] of greet.entries()) {
@@ -267,6 +283,8 @@ try {
const binary = join(directory, "ReaCalls");
await exec("/usr/bin/xcrun", [
"clang",
"-g",
"-O0",
"-fobjc-arc",
"-framework",
"Foundation",
+97
View File
@@ -0,0 +1,97 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { isAbsolute, join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import {
artifactCli,
artifactMcpResult,
withArtifactMcp,
} from "./lib/artifact-e2e.mjs";
const execute = promisify(execFile);
const compiler = process.env.REA_VERIFY_FPC_COMMAND;
if (process.platform !== "darwin" || !isAbsolute(compiler ?? ""))
throw new Error(
"Requires macOS and an absolute caller-selected REA_VERIFY_FPC_COMMAND; build the runtime first.",
);
const source = fileURLToPath(
new URL("../tests/conformance/native/calls.pas", import.meta.url),
);
const directory = await mkdtemp(join(tmpdir(), "rea-pascal-calls-"));
const input = {
breakpoints: [{ kind: "function", name: "rea_pascal_call" }],
argument_registers: 1,
backtrace_frames: 1,
duration_ms: 30_000,
};
const inspect = (result) => {
assert.equal(result.process.outcome, "exited");
assert.equal(result.process.exit_status, 0);
assert.equal(result.process.terminated, true);
assert.equal(result.process.stdout.text, "pascal 0\npascal 1\npascal 2\n");
assert.equal(result.events.length, 3);
assert.deepEqual(
result.events.map((event) => event.registers[0].value),
["0x0", "0x1", "0x2"],
);
assert.equal(result.breakpoints[0].location_count, 1);
for (const location of [
...result.events,
...result.breakpoints[0].locations,
]) {
assert.equal(location.source_location?.file_path, source);
assert.ok(location.source_location.line > 0);
}
};
const stable = (result) =>
result.events.map((event) => ({
file_address: event.file_address,
source_location: event.source_location,
registers: event.registers,
}));
try {
const version = (await execute(compiler, ["-iV"])).stdout.trim();
const architecture = (await execute(compiler, ["-iTP"])).stdout.trim();
await execute(compiler, [
"-dDEBUG",
"-gw3",
"-O-",
"-Xs-",
`-FE${directory}`,
`-FU${directory}`,
source,
]);
const binary = join(directory, "calls");
const cli = await artifactCli(
"observe-native-calls",
binary,
[JSON.stringify(input)],
{ timeoutMs: 180_000 },
);
inspect(cli);
await withArtifactMcp(binary, async (client) => {
const mcp = await artifactMcpResult(client, "observe_native_calls", input);
inspect(mcp);
assert.deepEqual(stable(mcp), stable(cli));
});
console.log(
JSON.stringify({
ok: true,
mocked: false,
compiler,
version,
architecture,
debugger: cli.debugger,
cli: true,
stdio_mcp: true,
scope:
"bounded native call entries and compiler-provided source coordinates; no typed Pascal locals or interactive stepping",
}),
);
} finally {
await rm(directory, { recursive: true, force: true });
}
@@ -114,6 +114,15 @@ export const nativeCodeLocationSchema = z.strictObject({
module: z.string().nullable(),
module_path: z.string().nullable(),
symbol: z.string().nullable(),
/** LLDB line-table attribution; does not verify source file contents. */
source_location: z
.strictObject({
file_path: z.string().min(1).nullable(),
line: z.number().int().positive().nullable(),
column: z.number().int().positive().nullable(),
})
.nullable()
.optional(),
});
/** One observed entry into a requested function or method. */
@@ -61,7 +61,16 @@ const tracer: NativeCallTracer & { calls: number } = {
breakpoints: request.input.breakpoints.map((_, index) => ({
index,
location_count: 1,
locations: [],
locations: [
{
load_address: "0x1000",
file_address: "0x1000",
module: "Tool",
module_path: request.executable,
symbol: "open",
source_location: { file_path: null, line: 23, column: null },
},
],
})),
events: [],
resource_limit_reached: false,
@@ -124,6 +133,11 @@ it("routes observe_native_calls through MCP with schema-checked input and output
.parse(called.structuredContent).normalized_result,
);
expect(result.process).toMatchObject({ pid: 99, outcome: "exited" });
expect(result.breakpoints[0]?.locations[0]?.source_location).toEqual({
file_path: null,
line: 23,
column: null,
});
expect(result.breakpoints[0]?.request).toEqual({
kind: "function",
name: "open",
@@ -50,6 +50,25 @@ const observation = (
};
describe("LLDB retained observations", () => {
it("preserves partial source coordinates while accepting historical omission", () => {
const attributed = {
...event,
source_location: {
file_path: "/missing/fixture.pas",
line: 23,
column: null,
},
};
const journal =
[event, attributed]
.map((entry) => JSON.stringify({ kind: "event", event: entry }))
.join("\n") + "\n";
expect(observation("cancelled", journal).events).toEqual([
event,
attributed,
]);
});
it.each([
["cancellation", "cancelled"],
["bridge failure", "tracer-failure"],
@@ -30,6 +30,11 @@ const LOCATION = {
module: "Fixture",
module_path: "/tmp/Fixture",
symbol: "-[Greeter greet:times:]",
source_location: {
file_path: "/not-acquired/é space/fixture.pas",
line: 23,
column: null,
},
};
const run = (
@@ -79,7 +84,7 @@ const run = (
{ name: "x0", value: "0x6000" },
{ name: "x1", value: "0x7000" },
],
backtrace: [],
backtrace: [{ ...LOCATION, source_location: null }],
},
],
other_stops: [],
@@ -202,7 +207,12 @@ describe("observe_native_calls projection: projects completed observations", ()
expect(result.events[0]).toMatchObject({
receiver_class: "Greeter",
selector: "greet:times:",
source_location: LOCATION.source_location,
backtrace: [{ ...LOCATION, source_location: null }],
});
expect(result.breakpoints[0]?.locations[0]?.source_location).toEqual(
LOCATION.source_location,
);
expect(result.coverage).toEqual({
status: "complete",
event_limit_reached: false,
+16
View File
@@ -0,0 +1,16 @@
program ReaPascalCalls;
{$mode objfpc}
{$inline off}
procedure ReaPascalCall(Value: LongInt); cdecl; public name 'rea_pascal_call';
begin
WriteLn('pascal ', Value);
end;
var
Index: LongInt;
begin
for Index := 0 to 2 do
ReaPascalCall(Index);
end.