From ac7ab63b9e1dfe4d37b82c7c7a3f71fe1ed25b14 Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Sun, 25 Oct 2026 21:36:13 +0100 Subject: [PATCH] test: lock down dependency diagnostic provenance --- README.md | 2 + TODO.md | 2 +- source/compilation/compilation.test.hon | 51 +++++++++++++++++++ source/loader/loader.hon | 4 +- tests/native.py | 65 +++++++++++++++++++++++++ 5 files changed, 122 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e699029..84c40b1 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,8 @@ the backend (`@source/llvm`) consumes the existing typed IR, emits native functi run the bootstrap suite with `bro test`. after `bro build`, run `python3 tests/native.py` for executable-level interpreter comparisons at both `-O0` and `-O2`, including traps, source diagnostics, array ownership, calling conventions, and CLI error handling. +dependency provenance checks cover lexer, parser, resolver, checker and lowering diagnostics, plus overflow, bounds, local initialization and global-cycle traps through transitive calls. they assert originating filenames and line/byte-column positions, compare reached and untaken paths, and run generated executables with the source tree temporarily removed. + the native bootstrap accepts either a no-argument `main` or `main proc(init process.Init)`. it supplies `init.io` and `init.args` from native startup `argc`/`argv`. startup scans each argument once into a heap-allocated array of borrowed zero-terminated slices (`process.Args.values: [][;0]u8`), without copying strings. the descriptor array and borrowed strings remain valid until `main` returns; startup then frees the descriptors, including on a fallible return. allocation failure exits with status `2`. `init.args.values.len` includes the executable name, and `init.args.values[index]` directly accesses an argument after a length guard. callers can supply their own `process.Args { values = slices[..] }` for testing. `process.run(allocator, arguments)` starts a child synchronously using Darwin `posix_spawnp`, searches `PATH`, and inherits the environment and standard streams. argument strings are borrowed zero-terminated slices; no shell parsing occurs. it releases temporary argument descriptors on every return, retries interrupted waits, and returns the exit code or `128 + signal` for signal termination. diff --git a/TODO.md b/TODO.md index 6b9dec4..db1aa08 100644 --- a/TODO.md +++ b/TODO.md @@ -22,7 +22,7 @@ needs records, pointers, generics and C calls. - [x] load directory packages once per canonical path, with deterministic file discovery, package scopes and root-only entry selection. follow Bro's existing relative and project-root `@` import rules; diagnose cycles rather than recurse indefinitely. - [x] finish postfix namespace selection and resolve file-local import aliases without injecting dependency names into the importing package; bare and aliased import declarations are already parsed for dependency discovery. - [x] reuse ordinary scalar signature checking, function values and IR calls for selected declarations; namespaces have no runtime representation. -- [ ] preserve originating filenames and spans in dependency diagnostics and native runtime traps; retain demand-driven diagnostic recovery. +- [x] preserve originating filenames and spans in dependency diagnostics and native runtime traps; retain demand-driven diagnostic recovery. - [ ] verify sibling source files, transitive/shared imports, same-spelled declarations in different packages, alias conflicts, missing packages/members, argument errors and dependency-file diagnostics. preserve existing single-file commands. visibility follows Bro's public-by-default contract; unsupported visibility diff --git a/source/compilation/compilation.test.hon b/source/compilation/compilation.test.hon index b9eb45f..4df7191 100644 --- a/source/compilation/compilation.test.hon +++ b/source/compilation/compilation.test.hon @@ -67,3 +67,54 @@ keeps_package_declarations_distinct_and_ir_independent test { try testing.expect_equal(i64(42), left_result.value.data.integer) try testing.expect_equal(i64(6), right_result.value.data.integer) } + +dependency_traps_keep_original_spans_and_remain_demand_driven test { + mains [2][]u8 :: [ + "dep :: import \"../dependency\"\nmain proc() int { return dep.fail(false) }\n", + "dep :: import \"../dependency\"\nmain proc() int { return dep.fail(true) }\n", + ] + prefix :: "\nfail proc(take bool) int {\n if take {\n return " + for (mains) |main, index| { + diagnostic_store := diagnostics.init(mem.c_allocator) + defer diagnostics.deinit(&diagnostic_store) + module ir.Module := undefined + dependency_source SourceId := undefined + original diagnostics.DiagnosticId := undefined + { + session := init(mem.c_allocator) + defer deinit(&session) + root :: try add_package(&session) + dependency :: try add_package(&session) + root_bytes := try owned_text(main) + defer arraylist.deinit(&root_bytes) + dependency_bytes := try owned_text( + "\nfail proc(take bool) int {\n if take {\n return missing\n }\n return 42\n}\n", + ) + defer arraylist.deinit(&dependency_bytes) + root_source :: try add_source(&session, root, "app/main.hon", &root_bytes, &diagnostic_store) + dependency_source = try add_source(&session, dependency, "dependency/fail.hon", &dependency_bytes, &diagnostic_store) + try arraylist.append(&session.imports, Import{ + node = declaration(&session, root_source, 0).node, + target = dependency, + }) + module = try compile(&session, root, &diagnostic_store) + } + defer ir.deinit(&module) + try testing.expect_equal(1, diagnostics.count(&diagnostic_store)) + original = diagnostics.DiagnosticId(0) + diagnostic :: diagnostics.get(&diagnostic_store, original)? + try testing.expect_equal(diagnostics.Code.unknown_name, diagnostic.code) + try testing.expect_equal(dependency_source, diagnostic.span.source) + try testing.expect_equal(u32(prefix.len), diagnostic.span.start) + try testing.expect_equal(u32(prefix.len + 7), diagnostic.span.end) + outcome := try interpreter.run(mem.c_allocator, &module, &diagnostic_store) + defer interpreter.deinit(&outcome) + try testing.expect_equal(1, diagnostics.count(&diagnostic_store)) + if (index == 0) { + try testing.expect(outcome.trap == null) + try testing.expect_equal(i64(42), outcome.value.data.integer) + } else { + try testing.expect_equal(original, outcome.trap?) + } + } +} diff --git a/source/loader/loader.hon b/source/loader/loader.hon index 294f27d..795312d 100644 --- a/source/loader/loader.hon +++ b/source/loader/loader.hon @@ -5,11 +5,13 @@ import "@std/arraylist" SourceError :: enum { source_too_large } -Error :: alias fs.Error +Error :: alias ( + fs.Error | SourceError | io.OpenError | io.CloseError | io.ReadError +) @hide:file copy proc(allocator mem.Allocator, text []u8) []mut u8 ! mem.AllocError { diff --git a/tests/native.py b/tests/native.py index 6947df5..0956569 100644 --- a/tests/native.py +++ b/tests/native.py @@ -429,6 +429,70 @@ fail proc() int { return 9223372036854775807 + 1 } return len(cases) + 4 +def check_dependency_provenance(temporary, executable): + project = temporary / "provenance" + app = project / "app" + dependency = project / "dependency" + app.mkdir(parents=True) + dependency.mkdir() + main = app / "main.hon" + source = dependency / "diagnostic.hon" + # A transitive call must report the leaf's location, not either importing file. + bridge = project / "bridge" + bridge.mkdir() + (bridge / "main.hon").write_text( + 'import "../dependency"\nfail proc() int { return dependency.fail() }\n' + ) + cases = [ + ("fail proc() int {\n return `\n}\n", 4, 12, True), + ("fail proc() int {\n return (1\n}\n", 4, 14, True), + ("fail proc() int {\n text :: \"é\" return missing\n}\n", 4, 25, True), + ("fail proc() int {\n if (1) return 0\n return 42\n}\n", 4, 9, True), + ("fail proc() int {\n return 9223372036854775808\n}\n", 4, 12, True), + ("fail proc() int {\n return 9223372036854775807 + 1\n}\n", 4, 12, False), + ("fail proc() int {\n a :: [42]\n return a[1]\n}\n", 5, 12, False), + ("fail proc() int {\n before :: after\n after :: 42\n return before\n}\n", 4, 15, False), + ("a int :: b\nb int :: a\nfail proc() int { return a }\n", 4, 10, False), + ] + for text, line, column, static in cases: + source.write_text("\n# utf8: é\n" + text) + location = f"{source.resolve()}:{line}:{column}: ".encode() + for reached in (False, True): + main.write_text( + 'import "../bridge"\nmain proc() int {\n' + f' if ({str(reached).lower()}) return bridge.fail()\n' + ' return 42\n}\n' + ) + interpreted = run(str(HONEY), "run", str(app)) + assert interpreted.returncode == int(static or reached), outcome(interpreted) + assert interpreted.stdout == (b"" if reached else b"42\n"), outcome(interpreted) + if static or reached: + label = b"runtime trap: " if reached else b"error: " + assert interpreted.stderr.startswith(location + label), outcome(interpreted) + assert len(interpreted.stderr.splitlines()) == 1, outcome(interpreted) + else: + assert interpreted.stderr == b"", outcome(interpreted) + emitted = run(str(HONEY), "emit-llvm", str(app)) + assert emitted.returncode == int(static), outcome(emitted) + expected_diagnostics = interpreted.stderr.replace(b"runtime trap: ", b"error: ") if static else b"" + assert emitted.stderr == expected_diagnostics, outcome(emitted) + for optimization in ("-O0", "-O2"): + built = run(str(HONEY), "build", str(app), "-o", str(executable), optimization) + assert outcome(built) == (int(static), b"", expected_diagnostics), outcome(built) + # Generated diagnostics must not reopen sources at runtime. + hidden = project.with_name("hidden-provenance") + project.rename(hidden) + try: + compiled = run(str(executable)) + assert outcome(compiled) == outcome(interpreted), ( + optimization, outcome(interpreted), outcome(compiled) + ) + finally: + hidden.rename(project) + executable.unlink() + return len(cases) * 2 + + def main(): with tempfile.TemporaryDirectory(prefix="honey native ' ; $") as directory: temporary = Path(directory) @@ -444,6 +508,7 @@ def main(): count += 1 count += check_packages(temporary, executable) count += check_namespace_packages(temporary, executable) + count += check_dependency_provenance(temporary, executable) source = ROOT / "programs/factorial.hon" emitted = run(str(HONEY), "emit-llvm", str(source)) assert emitted.returncode == 0 and emitted.stderr == b"", outcome(emitted)