From 42a4135711af7f206717c813b6f65bfdac2c664f Mon Sep 17 00:00:00 2001 From: Churkin Aleksey Date: Fri, 28 Aug 2026 10:26:24 +0300 Subject: [PATCH 1/4] utils: jit is a public tool, not an internal one The jit driver compiles a .das file through the LLVM backend without running it - prewarming the dll cache, building a standalone -exe, or emitting a native .o for static linking. All three are things an SDK user does, so the tool moves out of utils/internal/, where nothing installs. Making a utils/ directory external is a contract utils/REVIEW.das machine-checks: an install rule in CMakeLists.txt, an rst page plus its toctree row, and a layout row in install/CLAUDE.md. A row in ci/smoke_test_bundle.sh compiles the installed entry point, so a missing peer file in the bundle fails the release lane instead of the user's first run. The two build-side references (the DAS_LLVM_AOT_LIB batches in CMakeLists.txt, skills/internal/aot_testing.md) follow the path. --- CMakeLists.txt | 7 ++- ci/smoke_test_bundle.sh | 1 + doc/source/reference/utils.rst | 1 + doc/source/reference/utils/jit.rst | 94 ++++++++++++++++++++++++++++++ install/CLAUDE.md | 1 + skills/internal/aot_testing.md | 4 +- utils/CMakeLists.txt | 2 +- utils/{internal => }/jit/main.das | 0 8 files changed, 105 insertions(+), 5 deletions(-) create mode 100644 doc/source/reference/utils/jit.rst rename utils/{internal => }/jit/main.das (100%) diff --git a/CMakeLists.txt b/CMakeLists.txt index 8fdaca8f26..7b43657439 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -326,10 +326,10 @@ MACRO(DAS_LLVM_AOT_LIB input_files genList mainTarget) if(_batch_n EQUAL 32 OR _llvm_aot_idx EQUAL _llvm_aot_total) ADD_CUSTOM_COMMAND( OUTPUT ${_batch_objs} - DEPENDS daslang ${_batch_srcs} ${PROJECT_SOURCE_DIR}/utils/internal/jit/main.das ${_das_llvm_aot_codegen} + DEPENDS daslang ${_batch_srcs} ${PROJECT_SOURCE_DIR}/utils/jit/main.das ${_das_llvm_aot_codegen} WORKING_DIRECTORY ${PROJECT_SOURCE_DIR} COMMENT "LLVM-AOT compiling ${_batch_n} files" - COMMAND daslang ${PROJECT_SOURCE_DIR}/utils/internal/jit/main.das -- ${_batch_rels} --aot-object --aot-object-prefix ${mainTarget} + COMMAND daslang ${PROJECT_SOURCE_DIR}/utils/jit/main.das -- ${_batch_rels} --aot-object --aot-object-prefix ${mainTarget} ) set(_batch_rels "") set(_batch_srcs "") @@ -1903,6 +1903,9 @@ install(FILES ${PROJECT_SOURCE_DIR}/include/fast_float/LICENSE DESTINATION ${DAS # Install aot tool (the AOT generation driver the integration scaffolds invoke) install(FILES ${PROJECT_SOURCE_DIR}/utils/aot/main.das DESTINATION utils/aot) +# Install jit tool (the LLVM compile driver: dll cache prewarm, -exe, native .o) +install(FILES ${PROJECT_SOURCE_DIR}/utils/jit/main.das DESTINATION utils/jit) + # Install fix-lint-errors tool (mechanical lint auto-fixer) install(FILES ${PROJECT_SOURCE_DIR}/utils/fix-lint-errors/main.das DESTINATION utils/fix-lint-errors) diff --git a/ci/smoke_test_bundle.sh b/ci/smoke_test_bundle.sh index af1b1c68c0..afd787ecfa 100644 --- a/ci/smoke_test_bundle.sh +++ b/ci/smoke_test_bundle.sh @@ -76,6 +76,7 @@ COMPILE_TESTS=( "daspkg|utils/daspkg/main.das" "detect-dupe|utils/detect-dupe/main.das" "fix-lint-errors|utils/fix-lint-errors/main.das" + "jit|utils/jit/main.das" "jobque-timeline|utils/jobque-timeline/main.das" "lint|utils/lint/main.das" "mcp|utils/mcp/main.das" diff --git a/doc/source/reference/utils.rst b/doc/source/reference/utils.rst index c77a708b29..8446bfe683 100644 --- a/doc/source/reference/utils.rst +++ b/doc/source/reference/utils.rst @@ -22,6 +22,7 @@ built-in leak-detection mechanism. utils/daspkg.rst utils/benchctl.rst utils/aot.rst + utils/jit.rst utils/mcp.rst utils/lsp.rst utils/detect_dupe.rst diff --git a/doc/source/reference/utils/jit.rst b/doc/source/reference/utils/jit.rst new file mode 100644 index 0000000000..1213a76e78 --- /dev/null +++ b/doc/source/reference/utils/jit.rst @@ -0,0 +1,94 @@ +.. _utils_jit: + +.. index:: + single: Utils; jit + single: Utils; JIT + single: Utils; LLVM + +================================================== + jit --- LLVM Compile Driver +================================================== + +The jit tool compiles ``.das`` files through the LLVM backend without +running them. One run produces, depending on the mode, a cached shared +library (the JIT dll cache), a standalone executable, or a native ``.o`` +for static linking into a host binary. + +It accepts files and directories, so a whole tree can be compiled in one +go; a directory is walked recursively, skipping ``_``-prefixed entries +and the ``cant_`` / ``failed_`` / ``invalid_`` expected-failure tests. + +Requires a daslang built with the LLVM backend. + +.. contents:: + :local: + :depth: 2 + + +Quick start +=========== + +Prewarm the JIT dll cache for a tree, one worker per hardware thread:: + + daslang utils/jit/main.das -- path/to/dir --parallel 0 + +Build a standalone executable from a script:: + + daslang utils/jit/main.das -- my_script.das --exe -o my_script + +Emit a native object that registers its functions into the AOT library:: + + daslang utils/jit/main.das -- my_script.das --aot-object -o my_script + + +Modes +===== + +Shared library (default) + The target is compiled JIT-aware and the generated ``.dll`` is written + under ``.jitted_scripts//``, content-hashed, unless + ``--output`` names a path. A later run of the same script loads the + cached library instead of generating code again. + +``--exe`` + Emits a standalone executable. ``--jit-target`` cross-compiles (for + example ``wasm32-unknown-emscripten``), and + ``--jit-register-all-modules`` registers every builtin native module at + startup, which a standalone compiler-driver binary needs in order to + recompile arbitrary daslang at runtime. + +``--aot-object`` + Emits a native ``.o`` covering the used function set, with a load + constructor that registers those functions into the AOT library. + ``--aot-object-prefix`` derives one object per input under + ``/_llvm_aot_generated/``, so a single process can emit many + objects and amortize the dasLLVM load cost. + + +Parallel compilation +==================== + +``--parallel N`` spawns *N* worker subprocesses; ``--parallel 0`` (or the +flag with no value) picks the hardware thread count. Omitting the flag +compiles sequentially in-process. Workers are full subprocesses because +the compile pipeline is not thread-safe within one process. + +``--parallel`` is rejected together with ``--output`` — every file would +collide on the one path. + +``--exclude `` skips directories by their own name, at any depth, +and is repeatable. + + +Tuning flags +============ + +The JIT tuning flags are accepted alongside the tool's own — among them +``--jit-opt-level``, ``--jit-size-level``, ``--jit-debug``, ``--jit-stack``, +``--jit-dump``, ``--jit-split-modules``, ``--jit-path-to-linker`` and +``--jit-linker-string``. ``daslang utils/jit/main.das -- -?`` prints both +sets with their descriptions. + +.. seealso:: + + :ref:`utils_aot` -- the C++ AOT generation driver diff --git a/install/CLAUDE.md b/install/CLAUDE.md index 51cd42b38b..0eea7207a6 100644 --- a/install/CLAUDE.md +++ b/install/CLAUDE.md @@ -204,6 +204,7 @@ For path/filename ops use `fio` helpers (`base_name`/`dir_name`/`path_join`/...) - `utils/daspkg/`, `utils/dascov/` - package manager; code coverage - `utils/aot/` - AOT generation driver (`bin/daslang utils/aot/main.das -- -aot `; `-ctx` emits a standalone context dir - the integration tutorial scaffolds invoke it) - `utils/fix-lint-errors/` - auto-fixer for mechanical lint findings (`--dry-run` to preview) +- `utils/jit/` - LLVM compile driver (`bin/daslang utils/jit/main.das -- `): prewarms the JIT dll cache, `--exe` builds a standalone binary, `--aot-object` emits a native `.o` - `utils/benchctl/` - benchmark result database + statistical comparison (needs the sqlite module) - `utils/dasllama-server/` - OpenAI-compatible dasLLAMA inference server (JIT-only; `deploy-jit.ps1` builds a standalone bundle) - `utils/dasllama-convert/` - offline GGUF -> `.dlim` model prep diff --git a/skills/internal/aot_testing.md b/skills/internal/aot_testing.md index ec3e1c1e13..f8dbd77742 100644 --- a/skills/internal/aot_testing.md +++ b/skills/internal/aot_testing.md @@ -69,7 +69,7 @@ tests/aot/ | `DAS_AOT_EXT(files, genList, target, tool, extra)` | Core macro - others call this | `extra` alone (no implicit `-aot`; `DAS_AOT` passes `-aot` as the extra) | | `DAS_AOT_CTX(files, genList, target, tool)` | AOT with custom context | `-ctx` | | `DAS_AOT_STANDALONE(files, genList, target, tool, extra)` | AOT for a standalone binary | `extra` | -| `DAS_LLVM_AOT_LIB(files, genList, target)` | LLVM-backend AOT - emits native `.o`, not C++; runs `utils/internal/jit/main.das` in batches of 32 | `--aot-object` | +| `DAS_LLVM_AOT_LIB(files, genList, target)` | LLVM-backend AOT - emits native `.o`, not C++; runs `utils/jit/main.das` in batches of 32 | `--aot-object` | **Target name collision**: `DAS_AOT_EXT` creates a custom target named `${mainTarget}_genaot`. Multiple calls with the same `mainTarget` will collide. Use distinct target names (e.g., `test_aot_testing` and `test_aot_tests`). @@ -379,7 +379,7 @@ All `_aot_generated/` directories are covered by a single broad pattern in `.git ### The LLVM-AOT rail (`test_llvm_aot`) -There is a third binary beside `test_aot` / `test_aot_subset`. `test_llvm_aot` (LLVM-only, `EXCLUDE_FROM_ALL`, opt-in - not in ALL and not in CI) compiles each `.das` through the **LLVM backend** into a self-registering native `.o` (a `das_aot_register` load ctor), linked straight into the binary; `-use-aot` then binds each function as a `SimNode_Jit` via `linkCppAot`. It is built by `DAS_LLVM_AOT_LIB` (which drives `utils/internal/jit/main.das --aot-object`, not the C++ AOT tool) and run through the `run_tests_llvm_aot` target in `tests/CMakeLists.txt`. +There is a third binary beside `test_aot` / `test_aot_subset`. `test_llvm_aot` (LLVM-only, `EXCLUDE_FROM_ALL`, opt-in - not in ALL and not in CI) compiles each `.das` through the **LLVM backend** into a self-registering native `.o` (a `das_aot_register` load ctor), linked straight into the binary; `-use-aot` then binds each function as a `SimNode_Jit` via `linkCppAot`. It is built by `DAS_LLVM_AOT_LIB` (which drives `utils/jit/main.das --aot-object`, not the C++ AOT tool) and run through the `run_tests_llvm_aot` target in `tests/CMakeLists.txt`. **Its corpus is derived, so registering a suite enrols it here too.** `LLVM_AOT_TEST_FILES` is the accumulated `TEST_AOT_ALL_DAS` (every `reg`-flavor suite's test bodies) plus `tests/jit_tests/*.das`, minus `_`-prefixed files and a filter list (`cant_`, `llvm_tune`, `llvm_code`, `llvm_compile_only`, `dll_cache`, `jit_fastpath`, `typeinfo`, and all of `tests/msl/` + `tests/metal/`, which decline the JIT via `lattice_fallback`). Adding a directory to `DAS_AOT_SUITES` therefore silently adds it to the LLVM-AOT corpus - if it can't survive that rail, it needs a filter entry as well. diff --git a/utils/CMakeLists.txt b/utils/CMakeLists.txt index 8cb98140b0..0ed36d36ba 100644 --- a/utils/CMakeLists.txt +++ b/utils/CMakeLists.txt @@ -49,8 +49,8 @@ set(DAS_UTILS das-fmt detect-dupe lint + jit internal/hygiene - internal/jit ) if(NOT DAS_SQLITE_DISABLED) diff --git a/utils/internal/jit/main.das b/utils/jit/main.das similarity index 100% rename from utils/internal/jit/main.das rename to utils/jit/main.das From 8301729a920e2a2a4f6ca80c91e4df9447fafbff Mon Sep 17 00:00:00 2001 From: Churkin Aleksey Date: Fri, 28 Aug 2026 10:12:31 +0300 Subject: [PATCH 2/4] ast-fuzz: typed synthesis and the source-level probe matrices Typed mode builds each expression bottom-up from a requested type, so the program is well-typed by construction and inference runs to the end instead of stopping at the first error. Run mode gives the victim a main that calls every synthesized function, so the same program can be executed twice and its output compared. probe.das is the source-level counterpart to the AST generator: it writes ordinary source text, so a crash it finds has a repro anyone can paste. It carries the mismatch, cycle, garbage and corpus-mutation matrices, reports a probe that compiles but takes too long - judged against its own file's baseline, not a global one - and stores a corpus mutant as a recipe rather than a copy of the file. The mismatch matrix found the const-initializer folding crash and is gated now that the crash is fixed. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 1 + utils/internal/ast-fuzz/README.md | 72 ++ utils/internal/ast-fuzz/_ast_synth.das | 205 +++++ utils/internal/ast-fuzz/gen_prelude.das.in | 902 +++++++++++++++++++++ utils/internal/ast-fuzz/main.das | 14 + utils/internal/ast-fuzz/probe.das | 394 +++++++++ utils/internal/ast-fuzz/test_ast_fuzz.das | 51 ++ 7 files changed, 1639 insertions(+) create mode 100644 utils/internal/ast-fuzz/probe.das diff --git a/.gitignore b/.gitignore index fcbba81e14..205e64ae80 100644 --- a/.gitignore +++ b/.gitignore @@ -66,6 +66,7 @@ doc/source/stdlib/generated/ utils/internal/ast-fuzz/__*.das utils/internal/ast-fuzz/__*.log utils/internal/ast-fuzz/_ast_synth_all.das +utils/internal/ast-fuzz/__probes/ # Tutorial recordings: APNG intermediates land here from modules/dasImgui/tests # record_* drivers; the MP4 deliverables are staged here from the docs-assets # GitHub release by utils/internal/docs-assets/fetch — neither is tracked. diff --git a/utils/internal/ast-fuzz/README.md b/utils/internal/ast-fuzz/README.md index bc3aa7bb15..30740659ce 100644 --- a/utils/internal/ast-fuzz/README.md +++ b/utils/internal/ast-fuzz/README.md @@ -48,6 +48,47 @@ Four axes are opt-in, because each leaves parser-shaped AST behind: | `--synth-bind` | binds resolved pointers directly (`ExprVar.variable`, `ExprCall.func`, `ExprAddr.func`, `ExprConstEnumeration.enumType`, `ExprField.fieldIndex`) the way a macro does, rather than leaving the name for inference | | `--synth-pretype` | pre-sets `Expression._type`; infer treats a typed node as already inferred, so a wrong type reaches codegen | +### Typed mode (`--synth-typed`) + +The four axes above all generate *ill-typed* AST on purpose, which is what finds +crashes - but it also means inference stops at the first error, so 85-90% of runs +end in a compiler error and the deep half of the type checker is never reached. +Typed mode is the other half of the tool: it builds each expression bottom-up +from a requested type, so the program is **well-typed by construction**, every +seed compiles clean, and inference runs to the end - through overload resolution, +generic instantiation, make-local and codegen. + +``` +daslang utils/internal/ast-fuzz/main.das -- --bin bin/daslang --threads 16 \ + --seeds 4800 --synth-typed --synth-funcs 4 --synth-size 8 --synth-depth 4 +``` + +A compiler **error** in typed mode is a generator bug, not a finding: the whole +contract is that these programs are legal. What still counts is a crash, a hang, +or a verifier report. + +The type universe is closed and addressed by an int code, so a `TypeDecl` is +rebuilt fresh at every use and no node is ever shared between two slots: + +| Code | Types | +|---|---| +| basic | `int` `uint` `float` `double` `bool` `string` `int64` `uint64`, the int/float 2-3-4 vectors, `void` | +| `T_ARRAY + c` | `array` | +| `T_TABLE + c` | `table` | +| `T_STRUCT + i` | a synthesized structure whose fields are all basic codes | +| `T_PTR + i` | a pointer to one | + +What it reaches that random mode cannot: `[[S f0=...]]` make-struct literals and +CMRES returns, field reads, `new S` / `delete p`, `p ?? [[S ...]]` and +`p?.f0 ?? lit`, `t["k"] = v` writes with `t?["k"] ?? lit` reads, `a := b` and +`a <- b`, `invoke($ : T { return v })` closure blocks, string builders, `unsafe` +and `try`/`recover` blocks, and two **generics** (`def f(a) { return a }` and +`def f(a, b) { return a + b }`, both `auto`-argument and `auto`-result, filed via +`add_generic`) that instantiate afresh at every call site. + +Function results are real types with a matching `return`, and each function may +call the ones added before it, which keeps the call graph a DAG. + Handled-type annotations come from a fixed list of real types (`HANDLED_TYPES` in `_ast_synth.das`) rather than being enumerated: `module_find_type_annotation` `static_cast`s whatever `findAnnotation` returns, so asking it for `export` would @@ -95,6 +136,37 @@ hang *or* just a slow compile. Re-run with a larger `--timeout` to tell them apart. Hitting the `--memcap` ceiling is reported as `resource`: unbounded allocation on a pathological input is not a compiler crash. +## Source probes (`probe.das`) + +The generator builds trees no source can produce, so its findings are verifier checks by +construction. `probe.das` asks the same question about **ordinary source text**, where a +crash is a compiler bug with a repro anyone can paste: + +``` +daslang utils/internal/ast-fuzz/probe.das -- --bin bin/daslang --threads 12 +``` + +Three matrices, each a cross product written out as one tiny program per cell: + +| matrix | what it varies | expected | +|---|---|---| +| `--mismatch` | 23 declared types x 10 wrong initializers x 30 uses | compiler ERRORS; a crash is a bug | +| `--legal` | 20 legally typed values x the same 30 uses | nothing at all - this is the control | +| `--depth` | nesting to 8192 (parens, unary, `array<>`, `+` chains) | nothing at all | + +A compiler error is the *expected* outcome of the mismatch matrix - the probes are +deliberately ill-typed. Only an abnormal exit, a `CRASH:` banner or a timeout is reported, +and each one is written out as `__probes/crash_.das`. + +The mismatch matrix is what found the const-folding crash: a local `let` whose declared +type and initializer disagree propagates its constant to the variable's uses, and folding +then reads the literal's bits through the declared type's policy - `SIGSEGV at 0x2` for the +literal `2`, `0x40200000` for `2.5`. That crash is fixed - a mismatched initializer is no longer substituted for the declared +type (`ast_const_folding.cpp`, `tests/language/failed_const_init_type_folding.das`) - so all +three matrices are gated as tests: `test_probe_mismatch_matrix_never_crashes`, +`test_probe_legal_matrix_is_clean`, `test_probe_depth_matrix_is_clean`. The mismatch gate +takes `--sample 5` to stay affordable; drop `--sample` for the full 6900-cell sweep. + ## Verifier (`daslib/ast_verify`) Reports a clean diagnostic when an AST violates a structural invariant, instead diff --git a/utils/internal/ast-fuzz/_ast_synth.das b/utils/internal/ast-fuzz/_ast_synth.das index d6b6f0498b..50ad7f965f 100644 --- a/utils/internal/ast-fuzz/_ast_synth.das +++ b/utils/internal/ast-fuzz/_ast_synth.das @@ -98,6 +98,198 @@ def private fill_pools(var g : GenAll?; var pool : DeclPool; bind_refs : bool; p g.pre_typed = pre_typed } +// Typed mode: whole functions with a real result type and a body that returns +// it, so the program is well-typed and inference runs to the end. Every function +// may call the ones added before it, which keeps the call graph a DAG. +// Structures whose fields are all from the typed universe, so a make-struct +// literal for them is well-typed and the make-local paths get exercised. +def private add_typed_structs(mod : Module?; var g : GenAll?; seedInt, count : int) : void { + for (i in range(count)) { + var ts = TypedStruct(name = "TS{seedInt}_{i}") + var st <- new Structure(at = g.at, name := ts.name) + let nfields = 1 + i % 3 + ts.fields |> reserve(nfields) + for (f in range(nfields)) { + let fc = VALUE_CODES[g.rnd(length(VALUE_CODES))] + ts.fields |> push(fc) + add_structure_field(st, "f{f}", g.ty_decl(fc), null) + } + var stp = st + if (mod |> add_structure(st)) { + g.ts_ptr |> push(stp) + g.ts_fields |> push_clone(ts) + } + } +} + +// A callee whose trailing parameter is a block and whose leading parameters all have +// defaults. Calling it with the block alone forces inference down the piped-landing +// search, which nothing else in the generator reaches. +def private add_piped_helpers(mod : Module?; var g : GenAll?; seedInt : int; + at : LineInfo) : int { + var added = 0 + for (i in range(2)) { + let name = "tpipe_{seedInt}_{i}" + var fn <- new Function(at = at, atDecl = at, name := name, result = g.ty_decl(T_INT)) + for (a in range(1 + i)) { + var av <- new Variable(at = at, name := "p{a}", _type = g.ty_decl(T_INT), + init = new ExprConstInt(at = at, value = a + 1)) + fn.arguments |> emplace(av) + } + var bv <- new Variable(at = at, name := "blk", _type = g.block_type(T_INT)) + fn.arguments |> emplace(bv) + var call = new ExprInvoke(at = at, name := "invoke") + var bref = new ExprVar(at = at, name := "blk") + call.arguments |> emplace(bref) + var parg = new ExprVar(at = at, name := "p0") + call.arguments |> emplace(parg) + var body = new ExprBlock(at = at) + var r = new ExprReturn(at = at, subexpr = call) + body.list |> emplace(r) + fn.body = body + verify_function(fn) + if (mod |> add_function(fn)) { + added++ + g.piped_fns |> push(name) + } + } + return added +} + +// Two generic shapes with `auto` arguments and an `auto` result: every call site +// instantiates them again, which is the only way the generic machinery runs at all. +def private add_generic_passthrough(mod : Module?; seedInt : int; at : LineInfo; + var known : array) : int { + var added = 0 + for (k in range(2)) { + let two = k == 1 + var sig = TypedFn(name = two ? "tgen_{seedInt}_op" : "tgen_{seedInt}_id", + kind = two ? FN_GENERIC_OP : FN_GENERIC_ID) + var fn <- new Function(at = at, atDecl = at, name := sig.name, + result = new TypeDecl(at = at, baseType = Type.autoinfer)) + for (a in range(two ? 2 : 1)) { + var av <- new Variable(at = at, name := "a{a}", + _type = new TypeDecl(at = at, baseType = Type.autoinfer)) + fn.arguments |> emplace(av) + } + var body = new ExprBlock(at = at) + var value : ExpressionPtr + if (two) { + value = new ExprOp2(at = at, op := "+", left = new ExprVar(at = at, name := "a0"), + right = new ExprVar(at = at, name := "a1")) + } else { + value = new ExprVar(at = at, name := "a0") + } + var r = new ExprReturn(at = at, subexpr = value) + body.list |> emplace(r) + fn.body = body + verify_function(fn) + // an auto-argument function is a generic, and generics live in their own + // module slot - add_function would file it where no call site looks + if (mod |> add_generic(fn)) { + added++ + known |> push_clone(sig) + } + } + return added +} + +// Run mode: give the victim a main that calls every synthesized function with literal +// arguments and prints each result, so the same program can be executed twice - with and +// without the optimizer - and the two outputs compared. +def private emit_run_main(mod : Module?; var g : GenAll?; sigs : array; + at : LineInfo) : void { + var body = new ExprBlock(at = at) + for (sig in sigs) { + if (sig.kind != FN_CONCRETE) { + continue + } + var call = new ExprCall(at = at, name := sig.name) + for (a in sig.args) { + var arg = g.gen_val(a, g.max_depth - 1) + call.arguments |> emplace(arg) + } + if (sig.ret == T_VOID) { + body.list |> emplace(call) + continue + } + // print only what has a stable textual form; a pointer would differ per run + if (sig.ret >= T_STRUCT) { + var sink = new ExprLet(at = at) + var sv <- new Variable(at = at, name := "sink_{length(body.list)}", + _type = g.ty_decl(sig.ret), init = call) + sink.variables |> emplace(sv) + body.list |> emplace(sink) + continue + } + var sb = new ExprStringBuilder(at = at) + // the leading newline keeps the marker at the start of a line even when a + // generated body printed something without one + var tag = new ExprConstString(at = at, value := "\nR {sig.name} ") + sb.elements |> emplace(tag) + sb.elements |> emplace(call) + var nl = new ExprConstString(at = at, value := "\n") + sb.elements |> emplace(nl) + var pr = new ExprCall(at = at, name := "print") + pr.arguments |> emplace(sb) + body.list |> emplace(pr) + } + for_each_function(mod, "main") $(var fn) { + fn.body = body + fn.not_inferred() + } +} + +def private add_synth_typed_functions(mod : Module?; seedInt : int; count, depth, + size : int; at : LineInfo; run_mode : bool) : int { + var known : array + var added = add_generic_passthrough(mod, seedInt, at, known) + var decls = new GenAll(seed = random_seed(seedInt * 131 + 7), max_depth = depth, + run_mode = run_mode, at = at) + add_typed_structs(mod, decls, seedInt, 1 + abs(random_int(decls.seed)) % 3) + added += add_piped_helpers(mod, decls, seedInt, at) + for (i in range(count)) { + var g = new GenAll(seed = random_seed(seedInt + i * 7919), max_depth = depth, + run_mode = run_mode, at = at) + g.typed_fns |> push_clone_from(known) + g.ts_ptr |> push_from(decls.ts_ptr) + g.ts_fields |> push_clone_from(decls.ts_fields) + g.piped_fns |> push_clone_from(decls.piped_fns) + var sig = TypedFn(name = "tsynth_{seedInt}_{i}", kind = FN_CONCRETE, + ret = g.rnd(4) == 0 ? T_VOID : g.any_code()) + var fn <- new Function(at = at, atDecl = at, name := sig.name, + result = g.ty_decl(sig.ret)) + let nargs = g.rnd(4) + sig.args |> reserve(nargs) + for (a in range(nargs)) { + let ac = g.any_code() + sig.args |> push(ac) + let an = "a{a}" + var av <- new Variable(at = at, name := an, _type = g.ty_decl(ac)) + fn.arguments |> emplace(av) + g.tv_push(an, ac, false) + } + g.res_code = sig.ret + fn.body = g.gen_typed_body(size) + verify_function(fn) + unsafe { + delete g + } + if (mod |> add_function(fn)) { + added++ + known |> push_clone(sig) + } + } + if (run_mode) { + decls.typed_fns |> push_clone_from(known) + emit_run_main(mod, decls, known, at) + } + unsafe { + delete decls + } + return added +} + def private add_synth_functions(mod : Module?; seedInt : int; count : int; depth : int) : int { var seed = random_seed(seedInt * 31 + 17) var added = 0 @@ -150,6 +342,19 @@ class AstSynthMacro : AstPassMacro { let ndecls = prog._options |> find_arg("_ast_synth_decls") ?as tInt ?? 0 let bind_refs = prog._options |> find_arg("_ast_synth_bind") ?as tBool ?? false let pre_typed = prog._options |> find_arg("_ast_synth_pretype") ?as tBool ?? false + let typed = prog._options |> find_arg("_ast_synth_typed") ?as tBool ?? false + if (typed) { + var vat = LineInfo() + for_each_function(mod, "") $(fn) { + vat = fn.at + } + let run_mode = prog._options |> find_arg("_ast_synth_run") ?as tBool ?? false + let nadded = add_synth_typed_functions(mod, seedInt, nfuncs > 0 ? nfuncs : 3, + depth, size, vat, run_mode) + to_log(LOG_ERROR, "AST-SYNTH-TYPED: added {nadded} function(s)\n") + to_log(LOG_ERROR, "AST-SYNTH: seed={seedInt} typed depth={depth} size={size}\n") + return nadded > 0 + } var pool : DeclPool if (ndecls > 0) { add_synth_decls(mod, pool, seedInt, ndecls, depth) diff --git a/utils/internal/ast-fuzz/gen_prelude.das.in b/utils/internal/ast-fuzz/gen_prelude.das.in index 46fbda2e69..36a7199e4e 100644 --- a/utils/internal/ast-fuzz/gen_prelude.das.in +++ b/utils/internal/ast-fuzz/gen_prelude.das.in @@ -31,6 +31,76 @@ let private BASETYPES = fixed_array(Type.tInt, Type.tInt8, Type.tInt16, Type.tIn Type.tShort4, Type.tUShort4, Type.tByte4, Type.tUByte4, Type.tUInt2, Type.tUInt3, Type.tUInt4, Type.fakeContext, Type.anyArgument, Type.option) let private ALIASES = fixed_array("P", "C", "D", "V", "E", "B", "__nope") + +// Typed mode works over a closed type universe addressed by an int code, so a +// type is rebuilt fresh at every use and no TypeDecl is ever shared between two +// slots. Codes below N_BASIC index TY_NAMES / TY_BASETYPES; T_ARRAY + c is +// array, T_TABLE + c is table. +let public T_INT = 0 +let public T_UINT = 1 +let public T_FLOAT = 2 +let public T_DOUBLE = 3 +let public T_BOOL = 4 +let public T_STRING = 5 +let public T_INT64 = 6 +let public T_UINT64 = 7 +let public T_INT2 = 8 +let public T_INT3 = 9 +let public T_INT4 = 10 +let public T_FLOAT2 = 11 +let public T_FLOAT3 = 12 +let public T_FLOAT4 = 13 +let public T_VOID = 14 +let public T_ARRAY = 100 +let public T_TABLE = 200 +let public T_STRUCT = 300 +let public T_PTR = 400 +let public T_TUPLE = 600 +// the tuple shapes typed mode can build, as (first, second) code pairs +let private TUP_A = fixed_array(T_INT, T_STRING, T_FLOAT) +let private TUP_B = fixed_array(T_FLOAT, T_INT, T_FLOAT2) +let private TY_NAMES = fixed_array("int", "uint", "float", "double", "bool", + "string", "int64", "uint64", "int2", "int3", "int4", "float2", "float3", + "float4", "void") +let private TY_BASETYPES = fixed_array(Type.tInt, Type.tUInt, Type.tFloat, + Type.tDouble, Type.tBool, Type.tString, Type.tInt64, Type.tUInt64, + Type.tInt2, Type.tInt3, Type.tInt4, Type.tFloat2, Type.tFloat3, + Type.tFloat4, Type.tVoid) +let public VALUE_CODES = fixed_array(T_INT, T_UINT, T_FLOAT, T_DOUBLE, T_BOOL, + T_STRING, T_INT64, T_UINT64, T_INT2, T_INT3, T_INT4, T_FLOAT2, T_FLOAT3, + T_FLOAT4) +let private NUM_CODES = fixed_array(T_INT, T_UINT, T_FLOAT, T_DOUBLE, T_INT64, + T_UINT64) +let private INT_CODES = fixed_array(T_INT, T_UINT, T_INT64, T_UINT64) +let private VEC_CODES = fixed_array(T_INT2, T_INT3, T_INT4, T_FLOAT2, T_FLOAT3, + T_FLOAT4) +// `/` and `%` are absent on purpose: a folded division by a zero literal is its +// own bug class, and mixing it in here would swamp every other classification. +let private ARITH_OPS = fixed_array("+", "-", "*") +let private INT_OPS = fixed_array("+", "-", "*", "&", "|", "^") +let private CMP_OPS = fixed_array("==", "!=", "<", ">", "<=", ">=") +let private FLOAT_FNS = fixed_array("sqrt", "sin", "cos", "floor", "ceil", + "abs", "saturate") + +// kind 0 is a concrete signature; kind 1 is `def f(a) { return a }` with an auto +// argument and auto result, which instantiates afresh at every call site; kind 2 +// is the same but two arguments joined by a numeric operator. +let public FN_CONCRETE = 0 +let public FN_GENERIC_ID = 1 +let public FN_GENERIC_OP = 2 + +struct public TypedFn { + name : string + ret : int + kind : int + args : array +} + +struct public TypedStruct { + name : string + fields : array +} + class public GenAll { seed : int4 max_depth : int = 5 @@ -45,6 +115,16 @@ class public GenAll { bind_refs : bool = false pre_typed : bool = false @safe_when_uninitialized at : LineInfo + tv_names : array + tv_codes : array + tv_wr : array + typed_fns : array + @do_not_delete ts_ptr : array + ts_fields : array + piped_fns : array + run_mode : bool = false + res_code : int = T_VOID + n_named : int = 0 def rnd(n : int) : int { return n <= 1 ? 0 : abs(random_int(seed)) % n @@ -203,3 +283,825 @@ class public GenAll { return gen_any(depth, true) } + // ---- typed mode ------------------------------------------------------ + // Everything below builds bottom-up from a requested type code, so the + // result is well-typed by construction: inference runs to completion and + // the program reaches codegen, instead of stopping at the first error. + + def ty_decl(code : int) : TypeDeclPtr { // nolint:STYLE037 — one branch per type family; splitting hides the table + if (code >= T_TUPLE) { + var td = new TypeDecl(at = rat(), baseType = Type.tTuple) + var a = ty_decl(TUP_A[code - T_TUPLE]) + td.argTypes |> emplace(a) + var b = ty_decl(TUP_B[code - T_TUPLE]) + td.argTypes |> emplace(b) + return td + } + if (code >= T_PTR) { + var td = new TypeDecl(at = rat(), baseType = Type.tPointer) + td.firstType = ty_decl(T_STRUCT + code - T_PTR) + return td + } + if (code >= T_STRUCT) { + var td = new TypeDecl(at = rat(), baseType = Type.tStructure) + td.structType = ts_ptr[code - T_STRUCT] + return td + } + if (code >= T_TABLE) { + var td = new TypeDecl(at = rat(), baseType = Type.tTable) + td.firstType = new TypeDecl(at = rat(), baseType = Type.tString) + td.secondType = ty_decl(code - T_TABLE) + return td + } + if (code >= T_ARRAY) { + var td = new TypeDecl(at = rat(), baseType = Type.tArray) + td.firstType = ty_decl(code - T_ARRAY) + return td + } + return new TypeDecl(at = rat(), baseType = TY_BASETYPES[code]) + } + + def any_code() : int { + if (!empty(ts_ptr) && rnd(5) == 0) { + let i = rnd(length(ts_ptr)) + return (rnd(3) == 0 ? T_PTR : T_STRUCT) + i + } + if (rnd(9) == 0) { + return T_TUPLE + rnd(length(TUP_A)) + } + return VALUE_CODES[rnd(length(VALUE_CODES))] + } + + def is_tuple(code : int) : bool { + return code >= T_TUPLE + } + + def gen_tuple_val(code : int; depth : int) : ExpressionPtr { + let k = code - T_TUPLE + var e = new ExprMakeTuple(at = rat(), makeType = ty_decl(code)) + var a = gen_val(TUP_A[k], depth + 1) + e.values |> emplace(a) + var b = gen_val(TUP_B[k], depth + 1) + e.values |> emplace(b) + return e + } + + // `t._0` / `t._1` - tuple element access, one more route to a scalar + def gen_tuple_field(code : int; depth : int) : ExpressionPtr { + for (k in range(length(TUP_A))) { + let nm = tv_find(T_TUPLE + k) + if (empty(nm)) { + continue + } + if (TUP_A[k] == code) { + return new ExprField(at = rat(), value = var_ref(nm), name := "_0") + } + if (TUP_B[k] == code) { + return new ExprField(at = rat(), value = var_ref(nm), name := "_1") + } + } + return null + } + + def is_struct(code : int) : bool { + return code >= T_STRUCT && code < T_PTR + } + + def is_ptr(code : int) : bool { + return code >= T_PTR + } + + // block<(int):int>, the shape the piped-landing search is written for + def block_type(code : int) : TypeDeclPtr { + var td = new TypeDecl(at = rat(), baseType = Type.tBlock) + td.firstType = ty_decl(code) + // the parser marks a block argument const, and the block passed at the call + // site has to carry the same flag or the types do not match + var arg = ty_decl(T_INT) + arg.flags.constant = true + td.argTypes |> emplace(arg) + // a block parameter is const, and the value handed over at the call site is a + // const block too - a non-const parameter would reject it + td.flags.constant = true + return td + } + + def make_int_block(depth : int) : ExpressionPtr { + var blk = new ExprBlock(at = rat(), returnType = ty_decl(T_INT)) + blk.blockFlags.isClosure = true + var at1 = ty_decl(T_INT) + at1.flags.constant = true + var av <- new Variable(at = rat(), name := "bx", _type = at1) + blk.arguments |> emplace(av) + var body = new ExprOp2(at = rat(), op := "+", left = var_ref("bx"), + right = gen_lit(T_INT)) + var r = new ExprReturn(at = rat(), subexpr = body) + blk.list |> emplace(r) + return new ExprMakeBlock(at = rat(), _block = blk) + } + + // `f(blk)` where f is (a : int = 1; b : int = 2; blk : block<...>): the positional + // match fails and inference has to pad the defaults and land the block, which is the + // only way the piped-landing search runs. + def gen_piped_call(code : int; depth : int) : ExpressionPtr { + if (code != T_INT || empty(piped_fns)) { + return null + } + var e = new ExprCall(at = rat(), name := piped_fns[rnd(length(piped_fns))]) + var mb = make_int_block(depth) + e.arguments |> emplace(mb) + // the padding search only runs for a trailing-block call, which the parser marks + // with this flag - a plain positional call is rejected before it is reached + e.pipedCallArgument = true + return e + } + + def is_num(code : int) : bool { + return find_index(NUM_CODES, code) >= 0 + } + + def is_int_code(code : int) : bool { + return find_index(INT_CODES, code) >= 0 + } + + def is_vec(code : int) : bool { + return find_index(VEC_CODES, code) >= 0 + } + + def tv_push(name : string; code : int; writable : bool = true) : void { + tv_names |> push(name) + tv_codes |> push(code) + tv_wr |> push(writable) + } + + def tv_trim(n : int) : void { + tv_names |> resize(n) + tv_codes |> resize(n) + tv_wr |> resize(n) + } + + // A function argument is const, so a write target has to come from the + // writable half of the scope. + def tv_find_w(code : int; need_write : bool) : string { + var n = 0 + for (c, w in tv_codes, tv_wr) { + if (c == code && (w || !need_write)) { + n++ + } + } + if (n == 0) { + return "" + } + var pick = rnd(n) + for (nm, c, w in tv_names, tv_codes, tv_wr) { + if (c != code || !(w || !need_write)) { + continue + } + if (pick == 0) { + return nm + } + pick-- + } + return "" + } + + def tv_find(code : int) : string { + return tv_find_w(code, false) + } + + def fresh_name() : string { + n_named++ + return "v{n_named}" + } + + def var_ref(name : string) : ExpressionPtr { + return new ExprVar(at = rat(), name := name) + } + + def call1(name : string; var a : ExpressionPtr) : ExpressionPtr { + var e = new ExprCall(at = rat(), name := name) + e.arguments |> emplace(a) + return e + } + + def call2(name : string; var a : ExpressionPtr; var b : ExpressionPtr) : ExpressionPtr { + var e = new ExprCall(at = rat(), name := name) + e.arguments |> emplace(a) + e.arguments |> emplace(b) + return e + } + + def gen_lit(code : int) : ExpressionPtr { // nolint:STYLE037 — one branch per scalar type; splitting hides the table + if (code == T_UINT) { + return new ExprConstUInt(at = rat(), value = uint(rnd(200))) + } + if (code == T_FLOAT) { + return new ExprConstFloat(at = rat(), value = float(rnd(200) - 100) * 0.5f) + } + if (code == T_DOUBLE) { + return new ExprConstDouble(at = rat(), value = double(rnd(200) - 100) * 0.25lf) + } + if (code == T_BOOL) { + return new ExprConstBool(at = rat(), value = rnd(2) == 0) + } + if (code == T_STRING) { + return new ExprConstString(at = rat(), value := rname()) + } + if (code == T_INT64) { + return new ExprConstInt64(at = rat(), value = int64(rnd(10000))) + } + if (code == T_UINT64) { + return new ExprConstUInt64(at = rat(), value = uint64(rnd(10000))) + } + if (code == T_INT2) { + return new ExprConstInt2(at = rat(), value = int2(rnd(50), rnd(50))) + } + if (code == T_INT3) { + return new ExprConstInt3(at = rat(), value = int3(rnd(50), rnd(50), rnd(50))) + } + if (code == T_INT4) { + return new ExprConstInt4(at = rat(), + value = int4(rnd(50), rnd(50), rnd(50), rnd(50))) + } + if (code == T_FLOAT2) { + return new ExprConstFloat2(at = rat(), + value = float2(float(rnd(50)) * 0.5f, float(rnd(50)) * 0.5f)) + } + if (code == T_FLOAT3) { + return new ExprConstFloat3(at = rat(), + value = float3(float(rnd(50)) * 0.5f, float(rnd(50)) * 0.5f, + float(rnd(50)) * 0.5f)) + } + if (code == T_FLOAT4) { + return new ExprConstFloat4(at = rat(), + value = float4(float(rnd(50)) * 0.5f, float(rnd(50)) * 0.5f, + float(rnd(50)) * 0.5f, float(rnd(50)) * 0.5f)) + } + return new ExprConstInt(at = rat(), value = rnd(200) - 100) + } + + def gen_op2_typed(code : int; depth : int) : ExpressionPtr { + var op = "" + if (is_int_code(code)) { + op = INT_OPS[rnd(length(INT_OPS))] + } elif (is_num(code) || is_vec(code)) { + op = ARITH_OPS[rnd(length(ARITH_OPS))] + } else { + return gen_lit(code) + } + return new ExprOp2(at = rat(), op := op, left = gen_val(code, depth + 1), + right = gen_val(code, depth + 1)) + } + + def gen_builtin(code : int; depth : int) : ExpressionPtr { + if (code == T_FLOAT && rnd(2) == 0) { + return call1(FLOAT_FNS[rnd(length(FLOAT_FNS))], gen_val(T_FLOAT, depth + 1)) + } + if (rnd(2) == 0) { + return call1("abs", gen_val(code, depth + 1)) + } + return call2(rnd(2) == 0 ? "min" : "max", gen_val(code, depth + 1), + gen_val(code, depth + 1)) + } + + def fn_ok(f : TypedFn; code : int) : bool { + if (f.kind == FN_GENERIC_ID) { + return true + } + if (f.kind == FN_GENERIC_OP) { + return is_num(code) + } + return f.ret == code + } + + def gen_fn_call(code : int; depth : int) : ExpressionPtr { + var n = 0 + for (f in typed_fns) { + if (fn_ok(f, code)) { + n++ + } + } + if (n == 0) { + return null + } + var pick = rnd(n) + for (f in typed_fns) { + if (!fn_ok(f, code)) { + continue + } + if (pick > 0) { + pick-- + continue + } + var e = new ExprCall(at = rat(), name := f.name) + if (f.kind == FN_CONCRETE) { + for (a in f.args) { + var c = gen_val(a, depth + 1) + e.arguments |> emplace(c) + } + } else { + let nargs = f.kind == FN_GENERIC_OP ? 2 : 1 + for (_a in range(nargs)) { + var c = gen_val(code, depth + 1) + e.arguments |> emplace(c) + } + } + return e + } + return null + } + + // `invoke($ : T { return })` - a closure block whose result type is the + // requested one, so block inference and invoke resolution both run. + def gen_invoke_val(code : int; depth : int) : ExpressionPtr { + var blk = new ExprBlock(at = rat(), returnType = ty_decl(code)) + blk.blockFlags.isClosure = true + var r = new ExprReturn(at = rat(), subexpr = gen_val(code, depth + 1)) + blk.list |> emplace(r) + var mb = new ExprMakeBlock(at = rat(), _block = blk) + if (rnd(3) == 0) { + // both flags or neither: the verifier rejects a lambda make-block whose + // block is not itself marked as a lambda block + mb.mmFlags.isLambda = true + blk.blockFlags.isLambdaBlock = true + } + // `invoke` is its own node, not a call by that name + var e = new ExprInvoke(at = rat(), name := "invoke") + e.arguments |> emplace(mb) + return e + } + + def gen_bool_val(depth : int) : ExpressionPtr { + let pick = rnd(6) + if (pick == 0 || depth + 1 >= max_depth) { + return gen_lit(T_BOOL) + } + if (pick == 1) { + return new ExprOp1(at = rat(), op := "!", subexpr = gen_val(T_BOOL, depth + 1)) + } + if (pick == 2) { + let c = NUM_CODES[rnd(length(NUM_CODES))] + return new ExprOp2(at = rat(), op := CMP_OPS[rnd(length(CMP_OPS))], + left = gen_val(c, depth + 1), right = gen_val(c, depth + 1)) + } + if (pick == 3) { + return new ExprOp2(at = rat(), op := rnd(2) == 0 ? "&&" : "||", + left = gen_val(T_BOOL, depth + 1), right = gen_val(T_BOOL, depth + 1)) + } + if (pick == 4) { + let anm = tv_find(T_ARRAY + VALUE_CODES[rnd(length(VALUE_CODES))]) + if (!empty(anm)) { + return call1("empty", var_ref(anm)) + } + } + return gen_lit(T_BOOL) + } + + def gen_make_struct_val(code : int; depth : int) : ExpressionPtr { + let idx = code - T_STRUCT + var e = new ExprMakeStruct(at = rat(), makeType = ty_decl(code)) + var mks = new MakeStruct(uninitialized) + for (fc, fi in ts_fields[idx].fields, count()) { + var mf = new MakeFieldDecl(at = rat(), name := "f{fi}", + value = gen_val(fc, depth + 1)) + emplace_new(*mks, mf) + } + e.structs |> emplace(mks) + return e + } + + // `p ?? [[S ...]]` - the pointer form of null coalescing, which is the only + // way that path is reached without a source-level `??`. + def gen_deref_or_default(code : int; depth : int) : ExpressionPtr { + let pc = T_PTR + code - T_STRUCT + var ptr = gen_val(pc, depth + 1) + var fallback = gen_make_struct_val(code, depth + 1) + return new ExprNullCoalescing(at = rat(), subexpr = ptr, defaultValue = fallback) + } + + // `p?.f0 ?? ` - safe field through a pointer, then coalesce to the + // requested scalar type. + def gen_safe_field_val(code : int; depth : int) : ExpressionPtr { + for (ts, si in ts_fields, count()) { + for (fc, fi in ts.fields, count()) { + if (fc != code) { + continue + } + var ptr = gen_val(T_PTR + si, depth + 1) + var sf = new ExprSafeField(at = rat(), value = ptr, name := "f{fi}") + return new ExprNullCoalescing(at = rat(), subexpr = sf, + defaultValue = gen_lit(code)) + } + } + return null + } + + // `t?["k"] ?? ` - the read half of a table, without the insert-on-read + // that plain `t[k]` would do. + def gen_table_read(code : int; depth : int) : ExpressionPtr { + let tnm = tv_find(T_TABLE + code) + if (empty(tnm)) { + return null + } + var sa = new ExprSafeAt(at = rat(), subexpr = var_ref(tnm), + index = gen_lit(T_STRING)) + return new ExprNullCoalescing(at = rat(), subexpr = sa, + defaultValue = gen_lit(code)) + } + + def gen_string_builder(depth : int) : ExpressionPtr { + var e = new ExprStringBuilder(at = rat()) + for (_i in range(1 + rnd(3))) { + var c = gen_val(rnd(2) == 0 ? T_STRING : NUM_CODES[rnd(length(NUM_CODES))], + depth + 1) + e.elements |> emplace(c) + } + return e + } + + def gen_field_val(code : int; depth : int) : ExpressionPtr { + for (ts, si in ts_fields, count()) { + for (fc, fi in ts.fields, count()) { + if (fc != code) { + continue + } + let nm = tv_find(T_STRUCT + si) + if (empty(nm)) { + continue + } + return new ExprField(at = rat(), value = var_ref(nm), name := "f{fi}") + } + } + return null + } + + def gen_val(code : int; depth : int) : ExpressionPtr { // nolint:STYLE037 — one branch per value shape; splitting hides the table + if (is_tuple(code)) { + let nm = tv_find(code) + if (!empty(nm) && rnd(3) == 0) { + return var_ref(nm) + } + return gen_tuple_val(code, depth >= max_depth ? max_depth - 1 : depth) + } + if (is_ptr(code)) { + let nm = tv_find(code) + if (!empty(nm) && rnd(2) == 0) { + return var_ref(nm) + } + return new ExprNew(at = rat(), typeexpr = ty_decl(T_STRUCT + code - T_PTR)) + } + if (is_struct(code)) { + let nm = tv_find(code) + if (!empty(nm) && rnd(3) == 0) { + return var_ref(nm) + } + if (depth + 1 >= max_depth) { + return gen_make_struct_val(code, max_depth - 1) + } + if (rnd(4) == 0) { + return gen_deref_or_default(code, depth) + } + return gen_make_struct_val(code, depth) + } + if (code >= T_ARRAY || code == T_VOID || depth >= max_depth) { + return gen_lit(code) + } + let pick = rnd(18) + if (pick == 0) { + return gen_lit(code) + } + if (pick == 10) { + return gen_invoke_val(code, depth) + } + if (pick <= 2) { + let nm = tv_find(code) + if (!empty(nm)) { + return var_ref(nm) + } + } + if (pick == 3) { + return new ExprOp3(at = rat(), op := "?", + subexpr = gen_val(T_BOOL, depth + 1), left = gen_val(code, depth + 1), + right = gen_val(code, depth + 1)) + } + if (code == T_BOOL) { + return gen_bool_val(depth) + } + if (code == T_STRING) { + if (pick == 4) { + return call1("string", gen_val(NUM_CODES[rnd(length(NUM_CODES))], depth + 1)) + } + if (pick == 5) { + return new ExprOp2(at = rat(), op := "+", left = gen_val(T_STRING, depth + 1), + right = gen_val(T_STRING, depth + 1)) + } + return gen_lit(code) + } + if (pick == 4) { + let anm = tv_find(T_ARRAY + code) + if (!empty(anm)) { + return new ExprAt(at = rat(), subexpr = var_ref(anm), + index = safe_index(anm, depth)) + } + } + if (pick == 5 && is_num(code)) { + return call1(TY_NAMES[code], gen_val(NUM_CODES[rnd(length(NUM_CODES))], depth + 1)) + } + if (pick == 6 && is_vec(code)) { + return call1(TY_NAMES[code], gen_val(code <= T_INT4 ? T_INT : T_FLOAT, depth + 1)) + } + if (pick == 7) { + var c = gen_fn_call(code, depth) + if (c != null) { + return c + } + } + if (pick == 8 && is_num(code)) { + return gen_builtin(code, depth) + } + if (pick == 11) { + var fv = gen_field_val(code, depth) + if (fv != null) { + return fv + } + } + if (pick == 12) { + var sf = gen_safe_field_val(code, depth) + if (sf != null) { + return sf + } + } + if (pick == 13) { + var tr = gen_table_read(code, depth) + if (tr != null) { + return tr + } + } + if (pick == 14 && code == T_STRING) { + return gen_string_builder(depth) + } + if (pick == 15) { + var pc = gen_piped_call(code, depth) + if (pc != null) { + return pc + } + } + if (pick == 16) { + var tf = gen_tuple_field(code, depth) + if (tf != null) { + return tf + } + } + if (pick == 9 && (is_num(code) || is_vec(code))) { + return new ExprOp1(at = rat(), op := "-", subexpr = gen_val(code, depth + 1)) + } + return gen_op2_typed(code, depth) + } + + // `i` in compile-only mode, `abs(i) % length(a)` in run mode + def safe_index(arr : string; depth : int) : ExpressionPtr { + if (!run_mode) { + return gen_val(T_INT, depth + 1) + } + var idx = call1("abs", gen_val(T_INT, depth + 1)) + var len = call1("length", var_ref(arr)) + return new ExprOp2(at = rat(), op := "%", left = idx, right = len) + } + + def gen_typed_let(depth : int) : ExpressionPtr { + var e = new ExprLet(at = rat()) + let nm = fresh_name() + if (rnd(5) == 0) { + let ec = VALUE_CODES[rnd(length(VALUE_CODES))] + var av <- new Variable(at = rat(), name := nm, _type = ty_decl(T_ARRAY + ec)) + if (run_mode) { + // ExprMakeArray alone is a fixed dim; `[{T ...}]` is that wrapped in + // to_array_move, which is what yields array + var mk = new ExprMakeArray(at = rat(), makeType = ty_decl(ec)) + for (_i in range(3)) { + var el = gen_lit(ec) + mk.values |> emplace(el) + } + av.init = call1("to_array_move", mk) + av.flags.init_via_move = true + } + e.variables |> emplace(av) + tv_push(nm, T_ARRAY + ec) + return e + } + let code = any_code() + var v <- new Variable(at = rat(), name := nm, _type = ty_decl(code), + init = gen_val(code, depth + 1)) + e.variables |> emplace(v) + tv_push(nm, code) + return e + } + + def gen_typed_assign(depth : int) : ExpressionPtr { + let code = VALUE_CODES[rnd(length(VALUE_CODES))] + let nm = tv_find_w(code, true) + if (empty(nm)) { + return gen_typed_let(depth) + } + return new ExprCopy(at = rat(), op := "=", left = var_ref(nm), + right = gen_val(code, depth + 1)) + } + + def gen_typed_opassign(depth : int) : ExpressionPtr { + let code = NUM_CODES[rnd(length(NUM_CODES))] + let nm = tv_find_w(code, true) + if (empty(nm)) { + return gen_typed_let(depth) + } + return new ExprOp2(at = rat(), op := rnd(2) == 0 ? "+=" : "-=", + left = var_ref(nm), right = gen_val(code, depth + 1)) + } + + def gen_typed_push(depth : int) : ExpressionPtr { + let ec = VALUE_CODES[rnd(length(VALUE_CODES))] + let anm = tv_find_w(T_ARRAY + ec, true) + if (empty(anm)) { + return gen_typed_let(depth) + } + return call2("push", var_ref(anm), gen_val(ec, depth + 1)) + } + + def gen_typed_block(depth : int) : ExpressionPtr { + let mark = length(tv_names) + var b = new ExprBlock(at = rat()) + for (_i in range(1 + rnd(3))) { + var st = gen_typed_stmt(depth + 1) + b.list |> emplace(st) + } + tv_trim(mark) + return b + } + + def gen_typed_if(depth : int) : ExpressionPtr { + var e = new ExprIfThenElse(at = rat(), cond = gen_val(T_BOOL, depth + 1), + if_true = gen_typed_block(depth + 1)) + if (rnd(2) == 0) { + e.if_false = gen_typed_block(depth + 1) + } + return e + } + + def gen_typed_while(depth : int) : ExpressionPtr { + // `&& false` keeps the loop finite once the program is actually run, + // while the left side still goes through condition inference. + var cond = new ExprOp2(at = rat(), op := "&&", left = gen_val(T_BOOL, depth + 1), + right = new ExprConstBool(at = rat(), value = false)) + return new ExprWhile(at = rat(), cond = cond, body = gen_typed_block(depth + 1)) + } + + def add_iterator(var e : ExprFor?; name : string) : void { + let i = length(e.iterators) + e.iterators |> resize(i + 1) + e.iterators[i] := name + e.iteratorsAka |> resize(i + 1) + e.iteratorsAka[i] := "" + e.iteratorsTags |> resize(i + 1) + e.iteratorsAt |> push(rat()) + } + + def gen_typed_for(depth : int) : ExpressionPtr { + let mark = length(tv_names) + var e = new ExprFor(at = rat()) + let nm = fresh_name() + add_iterator(e, nm) + let ec = VALUE_CODES[rnd(length(VALUE_CODES))] + let anm = rnd(2) == 0 ? tv_find(T_ARRAY + ec) : "" + if (empty(anm)) { + var src = call1("range", gen_lit(T_INT)) + e.sources |> emplace(src) + tv_push(nm, T_INT, false) + } else { + var src = var_ref(anm) + e.sources |> emplace(src) + tv_push(nm, ec, false) + } + e.body = gen_typed_block(depth + 1) + tv_trim(mark) + return e + } + + // A table local is declared and then written through `t[k] = v`; reads go + // through the safe form so a read never inserts. + def gen_typed_table_let(depth : int) : ExpressionPtr { + var e = new ExprLet(at = rat()) + let nm = fresh_name() + let vc = VALUE_CODES[rnd(length(VALUE_CODES))] + var v <- new Variable(at = rat(), name := nm, _type = ty_decl(T_TABLE + vc)) + e.variables |> emplace(v) + tv_push(nm, T_TABLE + vc) + return e + } + + def gen_typed_table_write(depth : int) : ExpressionPtr { + let vc = VALUE_CODES[rnd(length(VALUE_CODES))] + let tnm = tv_find_w(T_TABLE + vc, true) + if (empty(tnm)) { + return gen_typed_table_let(depth) + } + var slot = new ExprAt(at = rat(), subexpr = var_ref(tnm), index = gen_lit(T_STRING)) + return new ExprCopy(at = rat(), op := "=", left = slot, + right = gen_val(vc, depth + 1)) + } + + // `a1 := a2` and `a1 <- a2` - the clone and move statement forms, which no + // scalar assignment reaches. + def gen_typed_move_or_clone(depth : int) : ExpressionPtr { + let ec = VALUE_CODES[rnd(length(VALUE_CODES))] + let dst = tv_find_w(T_ARRAY + ec, true) + if (empty(dst)) { + return gen_typed_let(depth) + } + let src = tv_find(T_ARRAY + ec) + if (empty(src) || src == dst) { + return gen_typed_let(depth) + } + if (rnd(2) == 0) { + return new ExprClone(at = rat(), op := ":=", left = var_ref(dst), + right = var_ref(src)) + } + return new ExprMove(at = rat(), op := "<-", left = var_ref(dst), + right = var_ref(src)) + } + + def gen_typed_delete(depth : int) : ExpressionPtr { + if (run_mode || empty(ts_ptr)) { + return gen_typed_let(depth) + } + let i = rnd(length(ts_ptr)) + let nm = tv_find_w(T_PTR + i, true) + if (empty(nm)) { + return gen_typed_let(depth) + } + // deleting a struct pointer needs unsafe, the same as it does in source + var body = new ExprBlock(at = rat()) + var del = new ExprDelete(at = rat(), subexpr = var_ref(nm)) + body.list |> emplace(del) + return new ExprUnsafe(at = rat(), body = body) + } + + def gen_typed_stmt(depth : int) : ExpressionPtr { // nolint:STYLE037 — one branch per statement shape; splitting hides the table + let pick = depth >= max_depth ? rnd(3) : rnd(17) + if (pick == 0 || pick == 1) { + return gen_typed_let(depth) + } + if (pick == 2) { + return gen_typed_assign(depth) + } + if (pick == 3) { + return gen_typed_if(depth) + } + if (pick == 4 || pick == 5) { + return gen_typed_for(depth) + } + if (pick == 6) { + return gen_typed_while(depth) + } + if (pick == 7) { + return gen_typed_block(depth) + } + if (pick == 8) { + return gen_typed_push(depth) + } + if (pick == 9) { + return gen_typed_opassign(depth) + } + if (pick == 11) { + return gen_typed_table_let(depth) + } + if (pick == 12) { + return gen_typed_table_write(depth) + } + if (pick == 13) { + return gen_typed_move_or_clone(depth) + } + if (pick == 14) { + return gen_typed_delete(depth) + } + if (pick == 15) { + return new ExprUnsafe(at = rat(), body = gen_typed_block(depth + 1)) + } + if (pick == 16) { + return new ExprTryCatch(at = rat(), try_block = gen_typed_block(depth + 1), + catch_block = gen_typed_block(depth + 1)) + } + return call1("print", gen_val(T_STRING, depth + 1)) + } + + def gen_typed_body(nstmt : int) : ExpressionPtr { + var b = new ExprBlock(at = rat()) + for (_i in range(nstmt)) { + var st = gen_typed_stmt(0) + b.list |> emplace(st) + } + if (res_code != T_VOID) { + var r = new ExprReturn(at = rat(), subexpr = gen_val(res_code, 1)) + b.list |> emplace(r) + } + return b + } + diff --git a/utils/internal/ast-fuzz/main.das b/utils/internal/ast-fuzz/main.das index 669727c3d9..484a75453b 100644 --- a/utils/internal/ast-fuzz/main.das +++ b/utils/internal/ast-fuzz/main.das @@ -62,6 +62,10 @@ struct Config { @clarg_doc = "Bind resolved pointers (ExprVar.variable, ExprCall.func, ExprConstEnumeration.enumType) directly, as a macro does, instead of leaving the name for inference" synth_bind : bool + @clarg_name = "synth-typed" + @clarg_doc = "Typed mode: synthesize whole functions that are well-typed by construction, so inference runs to completion and the program reaches codegen instead of stopping at the first error" + synth_typed : bool + @clarg_name = "synth-pretype" @clarg_doc = "Pre-set Expression._type on some nodes. Infer treats a typed node as already inferred, so this is how a wrong type reaches codegen" synth_pretype : bool @@ -130,6 +134,9 @@ def build_victim(cfg : Config; seed : int) : string { if (cfg.synth_pretype) { w |> write("options _ast_synth_pretype = true\n") } + if (cfg.synth_typed) { + w |> write("options _ast_synth_typed = true\n") + } if (cfg.synth_rflags) { w |> write("options _ast_synth_rflags = true\n") } @@ -151,6 +158,10 @@ def build_victim(cfg : Config; seed : int) : string { if (cfg.synth_report) { w |> write("options _ast_synth_report = true\n") } + if (cfg.synth_typed) { + // the typed pool calls math builtins, which have to be visible in the victim + w |> write("require math\n") + } w |> write("require _ast_synth\n") w |> write("\n") w |> write("[export]\n") @@ -232,6 +243,9 @@ def private child_flags(cfg : Config) : string { if (cfg.synth_pretype) { w |> write(" --synth-pretype") } + if (cfg.synth_typed) { + w |> write(" --synth-typed") + } if (cfg.synth_rflags) { w |> write(" --synth-rflags") } diff --git a/utils/internal/ast-fuzz/probe.das b/utils/internal/ast-fuzz/probe.das new file mode 100644 index 0000000000..2652da5119 --- /dev/null +++ b/utils/internal/ast-fuzz/probe.das @@ -0,0 +1,394 @@ +options gen2 +options indenting = 4 + +require daslib/clargs +require daslib/fio +require daslib/strings_boost +require math + +// Source-level probe matrix. Where the AST generator builds trees no source can +// produce, this builds ordinary source text and asks the same question: does the +// compiler survive it? Three matrices: +// +// mismatch a local whose declared type and initializer disagree, then a use that +// inference const-folds - the shape that found the string/bitfield crashes +// legal the same uses over legally typed values, so a finding here needs no +// type error at all (a control: this one is expected to stay empty) +// depth deeply nested expressions and types, for stack exhaustion +// +// A compiler ERROR is the expected outcome for the mismatch matrix - only a crash, a +// hang or an abnormal exit counts. + +let PROBE_DIR = "utils/internal/ast-fuzz/__probes" + +let PRELUDE = "options gen2\n\nstruct S \{\n a : int\n\}\n\nenum E \{\n a\n b\n\}\n\nbitfield BF \{\n m\n n\n\}\n\nvariant V \{\n i : int\n s : string\n\}\n" + +let MISMATCH_TYPES = fixed_array("int", "uint", "int64", "int8", "float", "double", + "bool", "string", "int2", "float3", "range", "array", "table", + "int?", "tuple", "V", "iterator", "block<() : void>", + "lambda<() : void>", "S", "S?", "E", "BF") + +let WRONG_LITS = fixed_array("2", "2.5", "\"s\"", "true", "2l", "2u", "null", "'c'", + "E.a", "[[S a = 1]]") + +let USES = fixed_array("var z = x + x", "var z = x + 2", "var z = x + \"a\"", + "var z = x == x", "var z = x < x", "var z = -x", "var z = !x", "x ++", + "var z = x[0]", "var z = x.a", "var z = x?.a", "var z = *x", "var z = x ?? 1", + "var z = x is i", "var z = x as i", "var z = int(x)", "var z = string(x)", + "var z = hash(x)", "var z = length(x)", "var z = invoke(x)", "for (q in x) \{ \}", + "var z := x", "var z <- x", "unsafe \{ delete x \}", "x |> push(1)", + "var z = \"\{x\}\"", "assert(x)", "var z = true ? x : x", + "var z = typeinfo sizeof(x)", "var z = typeinfo typename(x)") + +let LEGAL_DECLS = fixed_array("let x = 2", "let x = 2u", "let x = 2l", "let x = 2.5", + "let x = 2.5lf", "let x = true", "let x = \"s\"", "let x = int2(1, 2)", + "let x = float3(1.0, 2.0, 3.0)", "let x = range(0, 4)", "var x = [\{int 1; 2\}]", + "var x <- \{ \"k\" => 1 \}", "var x = new int", "var x = new S", + "let x = [[S a = 1]]", "let x = E.a", "let x = BF.m", "let x = BF.m | BF.n", + "let x = [[V i = 1]]", "let x = [[tuple 1, \"s\"]]") + +let DEPTHS = fixed_array(8, 32, 128, 512, 2048, 8192) + +// Cycles and self-reference: a declaration that refers to itself, directly or through a +// second declaration. Each one must be diagnosed, not chased until something breaks. +let CYCLES = fixed_array( + "struct S \{\n a : S\n\}", + "struct A \{\n b : B\n\}\nstruct B \{\n a : A\n\}", + "struct S \{\n a : S?\n b : array\n\}", + "variant V \{\n a : V\n\}", + "variant V \{\n a : tuple\n\}", + "class C : C \{\n\}", + "class A : B \{\n\}\nclass B : A \{\n\}", + "typedef T = T", + "typedef A = B\ntypedef B = A", + "var g = g", + "var a = b\nvar b = a", + "let a = b\nlet b = a", + "enum E \{\n a = 1\n b = int(E.a) + 1\n\}", + "struct S \{\n a : int = S().a\n\}", + "def f(a) \{\n return f(a)\n\}\ndef probe2 \{\n let z = f(1)\n\}", + "def f(a) \{\n return f([\{typeof(a) a\}])\n\}\ndef probe2 \{\n let z = f(1)\n\}", + "def f(a) \{\n return f(f(a))\n\}\ndef probe2 \{\n let z = f(1)\n\}", + "def f(a : auto(TT)) : TT \{\n return f(a)\n\}\ndef probe2 \{\n let z = f(1)\n\}", + "struct S \{\n a : int\n\}\ndef S(x : S) : S \{\n return S(x)\n\}", + "template t(a) \{\n return t(a)\n\}") + +// Structural garbage: text that is not a program. The parser has to reject it, and the +// error path has to survive whatever partial tree it built. +// Token-for-token swaps that keep a file parseable, so the mutant reaches inference +// instead of dying in the parser. The var->let row is the class that found the +// const-initializer folding crash. +// Tokens spliced in during mutation: enough to derail a parse without being noise. +let GARBAGE = fixed_array( + "def", "def f", "def f \{", "def f \{\n let", "let x =", + "def f \{\n return\n", "struct", "struct S \{", "enum E \{", + "\}\n\}\n\}", "def f() : \{\n\}", "def f(: int) \{\n\}", + "def f \{\n x = = 1\n\}", "def f \{\n ((((\n\}", + "def f \{\n let x : = 1\n\}", "def f \{\n for (in) \{\n \}\n\}", + "options", "options =", "options gen2 = ", "require", + "def f \{\n var x <- <- 1\n\}", "def f \{\n x?.?.y\n\}", + "def f \{\n [[\n\}", "def f \{\n \{\{\n\}", + "def f \{\n unsafe\n\}", "def f \{\n return return\n\}", + "class", "variant V \{", "bitfield B \{", "typedef", + "def f \{\n a := := b\n\}", "def f \{\n \\\\\n\}") + +[CommandLineArgs] +struct Config { + @clarg_short = "b" + @clarg_doc = "Path to the daslang binary under test (default: bin/daslang)" + bin : string + + @clarg_doc = "Run the sweep in N parallel shards (default 1)" + threads : int + + @clarg_doc = "Shard id, 1-based. Set by --threads; pass by hand to run one shard" + shard : int + + @clarg_name = "shard-count" + @clarg_doc = "Total shard count, set together with --shard" + shard_count : int + + @clarg_doc = "Per-probe timeout in seconds (default 20)" + timeout : float + + @clarg_doc = "Only the mismatch matrix (declared type vs initializer)" + mismatch : bool + + @clarg_doc = "Only the legal matrix - the control that should report nothing" + legal : bool + + @clarg_doc = "Only the depth probes" + depth : bool + + @clarg_doc = "Only the cycle probes - declarations that refer to themselves" + cycles : bool + + @clarg_doc = "Only the structural-garbage probes - text that is not a program" + garbage : bool + + + + + + @clarg_doc = "Keep every generated probe instead of only the ones that crashed" + keep : bool + + @clarg_doc = "Run only every Nth probe. The gate samples the matrix; a full sweep does not" + sample : int + + @clarg_name = "slow-secs" + @clarg_doc = "Report a probe that COMPILES but takes longer than this (default 8). A compile-time blowup that finishes is invisible to the timeout, so it is tracked separately" + slow_secs : float + + @clarg_short = "?" + @clarg_name = "show-help" + @clarg_doc = "Show this help and exit" + help : bool +} + +struct private Probe { + name : string + text : string +} + + +// a type spelling is not a filename, so flatten it +def private slug(ty : string) : string { + var s = ty |> replace(" ", "") + s = s |> replace("<", "_") + s = s |> replace(">", "") + s = s |> replace(";", "") + s = s |> replace("?", "p") + s = s |> replace("(", "") + s = s |> replace(")", "") + s = s |> replace(":", "") + s = s |> replace(",", "") + return s +} + +def private mismatch_probes(var out : array) : void { + out |> reserve(length(out) + length(MISMATCH_TYPES) * length(WRONG_LITS) * length(USES)) + for (ty in MISMATCH_TYPES) { + let tyn = slug(ty) + for (lit, li in WRONG_LITS, count()) { + for (use, ui in USES, count()) { + var p = Probe(name = "m_{tyn}_{li}_{ui}", + text = "{PRELUDE}\ndef probe \{\n let x : {ty} = {lit}\n {use}\n\}\n") + out |> push_clone(p) + } + } + } +} + +def private legal_probes(var out : array) : void { + out |> reserve(length(out) + length(LEGAL_DECLS) * length(USES)) + for (decl, di in LEGAL_DECLS, count()) { + for (use, ui in USES, count()) { + var p = Probe(name = "l_{di}_{ui}", + text = "{PRELUDE}\ndef probe \{\n {decl}\n {use}\n\}\n") + out |> push_clone(p) + } + } +} + +def private rep_str(s : string; n : int) : string { + return build_string() $(var w) { + for (_i in range(n)) { + w |> write(s) + } + } +} + +def private depth_probes(var out : array) : void { + out |> reserve(length(out) + length(DEPTHS) * 4) + for (d in DEPTHS) { + var p = Probe(name = "d_paren_{d}", + text = "options gen2\ndef probe \{\n var z = {rep_str("(", d)}1{rep_str(")", d)}\n\}\n") + out |> push_clone(p) + var q = Probe(name = "d_neg_{d}", + text = "options gen2\ndef probe \{\n var z = {rep_str("-", d)}1\n\}\n") + out |> push_clone(q) + var r = Probe(name = "d_type_{d}", + text = "options gen2\ndef probe \{\n var z : {rep_str("array<", d)}int{rep_str(">", d)}\n\}\n") + out |> push_clone(r) + var t = Probe(name = "d_add_{d}", + text = "options gen2\ndef probe \{\n var z = 1{rep_str(" + 1", d)}\n\}\n") + out |> push_clone(t) + } +} + +def private cycle_probes(var out : array) : void { + out |> reserve(length(out) + length(CYCLES)) + for (decl, di in CYCLES, count()) { + var p = Probe(name = "c_{di}", + text = "options gen2\n{decl}\n\ndef probe \{\n\}\n") + out |> push_clone(p) + } +} + +def private garbage_probes(var out : array) : void { + out |> reserve(length(out) + length(GARBAGE)) + for (txt, gi in GARBAGE, count()) { + var p = Probe(name = "g_{gi}", text = "options gen2\n{txt}\n") + out |> push_clone(p) + } +} + +def private build_probes(cfg : Config) : array { + var all : array + let any = cfg.mismatch || cfg.legal || cfg.depth || cfg.cycles || cfg.garbage + if (!any || cfg.mismatch) { + mismatch_probes(all) + } + if (!any || cfg.legal) { + legal_probes(all) + } + if (!any || cfg.depth) { + depth_probes(all) + } + if (!any || cfg.cycles) { + cycle_probes(all) + } + if (!any || cfg.garbage) { + garbage_probes(all) + } + return <- all +} + +def private is_crash(exit_code : int; out : string) : bool { + if (exit_code == popen_timed_out) { + return false + } + if (exit_code >= 128 || exit_code < 0) { + return true + } + return find(out, "CRASH:") >= 0 || find(out, "Segmentation fault") >= 0 +} + +def private run_shard(cfg : Config; bin : string; probes : array; + shard, shard_count : int; timeout : float) : int { + var nCrash = 0 + var nHang = 0 + var nSlow = 0 + var nRun = 0 + let slowLimit = cfg.slow_secs > 0.0 ? cfg.slow_secs : 8.0 + let path = "{PROBE_DIR}/__probe{shard}.das" + let step = max(cfg.sample, 1) + for (p, i in probes, count()) { + if (i % step != 0 || (i / step) % shard_count != shard - 1) { + continue + } + nRun++ + let ptext = p.text + if (!fwrite(path, ptext)) { + to_log(LOG_ERROR, "probe: cannot write {path}\n") + return 2 + } + var out : string + let t0 = ref_time_ticks() + let ec = run_and_capture([bin, "-compile-only", path], out, timeout) + let secs = float(get_time_usec(t0)) / 1000000.0 + if (secs > slowLimit && ec != popen_timed_out) { + nSlow++ + to_log(LOG_ERROR, "probe: SLOW {p.name} {secs} s\n") + fwrite("{PROBE_DIR}/slow_{p.name}.das", ptext) + } + if (ec == popen_timed_out) { + nHang++ + to_log(LOG_ERROR, "probe: HANG {p.name}\n") + fwrite("{PROBE_DIR}/hang_{p.name}.das", ptext) + } elif (is_crash(ec, out)) { + nCrash++ + let banner = find(out, "CRASH:") >= 0 ? slice(out, find(out, "CRASH:")) : "" + to_log(LOG_ERROR, "probe: CRASH {p.name} exit={ec} {strip(slice(banner, 0, min(length(banner), 70)))}\n") + fwrite("{PROBE_DIR}/crash_{p.name}.das", ptext) + } elif (cfg.keep) { + fwrite("{PROBE_DIR}/kept_{p.name}.das", ptext) + } + } + to_log(LOG_INFO, "probe: shard {shard}/{shard_count} — {nRun} probe(s), {nCrash} CRASH, {nHang} HANG, {nSlow} SLOW\n") + return nCrash + nHang + nSlow > 0 ? 1 : 0 +} + +def private run_threaded(cfg : Config; bin : string; threads : int; + nprobes : int) : int { + let cmd = build_string() $(var w) { + for (i in range(threads)) { + w |> write("nice -n 10 '{bin}' utils/internal/ast-fuzz/probe.das -- --bin '{bin}'") + w |> write(" --shard {i + 1} --shard-count {threads}") + if (cfg.mismatch) { + w |> write(" --mismatch") + } + if (cfg.legal) { + w |> write(" --legal") + } + if (cfg.depth) { + w |> write(" --depth") + } + if (cfg.cycles) { + w |> write(" --cycles") + } + if (cfg.garbage) { + w |> write(" --garbage") + } + if (cfg.slow_secs > 0.0) { + w |> write(" --slow-secs {cfg.slow_secs}") + } + if (cfg.sample > 1) { + w |> write(" --sample {cfg.sample}") + } + if (cfg.timeout > 0.0) { + w |> write(" --timeout {cfg.timeout}") + } + w |> write(" > {PROBE_DIR}/__shard{i + 1}.log 2>&1 & ") + } + w |> write("wait") + } + to_log(LOG_INFO, "probe: {nprobes} probe(s) across {threads} shard(s)\n") + var out : string + let ec = run_and_capture(["/bin/sh", "-c", cmd], out, 60.0 * 60.0) + if (ec == popen_timed_out) { + to_log(LOG_ERROR, "probe: the sweep exceeded its hour budget\n") + return 2 + } + var crashes = 0 + for (i in range(threads)) { + for (ln in split(fread("{PROBE_DIR}/__shard{i + 1}.log"), "\n")) { + if (find(ln, "CRASH ") >= 0 || find(ln, "HANG ") >= 0 + || find(ln, "SLOW ") >= 0) { + to_log(LOG_ERROR, " {strip(ln)}\n") + crashes++ + } elif (find(ln, "probe(s),") >= 0) { + to_log(LOG_INFO, " {strip(ln)}\n") + } + } + } + to_log(LOG_INFO, "probe: {crashes} finding(s); repros in {PROBE_DIR}/crash_*.das\n") + return crashes > 0 ? 1 : 0 +} + +[export] +def main : int { + var r <- parse_args(type) + if (r |> is_err) { + to_log(LOG_ERROR, "error: {r |> unwrap_err}\n") + print_help(get_command_info(type), "probe") + return 2 + } + let cfg <- r |> move_unwrap + if (cfg.help) { + print_help(get_command_info(type), "probe") + return 0 + } + let bin = empty(cfg.bin) ? "bin/daslang" : cfg.bin + let timeout = cfg.timeout > 0.0 ? cfg.timeout : 20.0 + mkdir(PROBE_DIR) + let probes <- build_probes(cfg) + if (cfg.shard > 0 && cfg.shard_count > 0) { + return run_shard(cfg, bin, probes, cfg.shard, cfg.shard_count, timeout) + } + if (cfg.threads > 1) { + return run_threaded(cfg, bin, cfg.threads, length(probes)) + } + return run_shard(cfg, bin, probes, 1, 1, timeout) +} diff --git a/utils/internal/ast-fuzz/test_ast_fuzz.das b/utils/internal/ast-fuzz/test_ast_fuzz.das index d828d7ebf5..c3c01a8b46 100644 --- a/utils/internal/ast-fuzz/test_ast_fuzz.das +++ b/utils/internal/ast-fuzz/test_ast_fuzz.das @@ -174,3 +174,54 @@ def test_generator_runs_and_classifies(t : T?) { t |> success(!died(ec, out), "driver itself crashed or hung (exit {ec})\n{out}") t |> success(find(out, "run(s) —") >= 0, "driver produced no summary\n{out}") } + +// Typed mode has to compile CLEAN - that is the whole point of it: a well-typed +// program runs inference to the end and reaches codegen, so any compiler error +// here is a generator bug, not a finding. +[test] +def test_typed_mode_compiles_clean(t : T?) { + var out : string + let ec = run([BIN, "utils/internal/ast-fuzz/main.das", "--", "--bin", BIN, + "--seeds", "3", "--synth-typed", "--synth-funcs", "3", "--synth-size", "6", + "--synth-depth", "3", "--timeout", "60"], out, 300.0) + t |> success(!died(ec, out), "driver itself crashed or hung (exit {ec})\n{out}") + t |> success(find(out, "3 clean") >= 0, + "typed mode did not compile clean; a compiler error means the generator built ill-typed AST\n{out}") +} + +// The mismatch matrix is deliberately ill-typed - every probe must produce a compiler +// ERROR, never a crash. This is the matrix that found the const-initializer folding +// crash, so it stays gated. The gate samples every fifth cell to stay affordable; a full +// sweep is `probe.das -- --mismatch --threads N` with no --sample. +[test] +def test_probe_mismatch_matrix_never_crashes(t : T?) { + var out : string + let ec = run([BIN, "utils/internal/ast-fuzz/probe.das", "--", "--bin", BIN, + "--mismatch", "--threads", "8", "--sample", "5", "--timeout", "30"], out, 1800.0) + t |> success(!died(ec, out), "probe tool itself crashed or hung (exit {ec})\n{out}") + t |> success(find(out, "0 finding(s)") >= 0, + "an ill-typed probe crashed the compiler instead of reporting an error\n{out}") +} + +// The legal matrix is the control: every value in it is correctly typed, so the +// compiler must survive all of it. A finding here needs no type error at all. +[test] +def test_probe_legal_matrix_is_clean(t : T?) { + var out : string + let ec = run([BIN, "utils/internal/ast-fuzz/probe.das", "--", "--bin", BIN, + "--legal", "--threads", "8", "--timeout", "30"], out, 900.0) + t |> success(!died(ec, out), "probe tool itself crashed or hung (exit {ec})\n{out}") + t |> success(find(out, "0 finding(s)") >= 0, + "a legally typed probe crashed the compiler\n{out}") +} + +// Deeply nested expressions and types must error or compile, never exhaust the stack. +[test] +def test_probe_depth_matrix_is_clean(t : T?) { + var out : string + let ec = run([BIN, "utils/internal/ast-fuzz/probe.das", "--", "--bin", BIN, + "--depth", "--threads", "8", "--timeout", "30"], out, 600.0) + t |> success(!died(ec, out), "probe tool itself crashed or hung (exit {ec})\n{out}") + t |> success(find(out, "0 finding(s)") >= 0, + "a nesting-depth probe crashed the compiler\n{out}") +} From 4d57d5dc364a4f744b2ca2f52526be2618cd13e0 Mon Sep 17 00:00:00 2001 From: Churkin Aleksey Date: Fri, 28 Aug 2026 10:12:43 +0300 Subject: [PATCH 3/4] ast-fuzz: mutate the AST of a real program The generator builds trees from nothing, which is why its findings are shapes only a macro can build. Mutation starts from a real parsed program and applies edits a person could have typed - a different operator, literal, argument order, name, declared type, signature or nesting - so inference sees an ordinary program and a crash is a compiler bug. Every mutation stays source-expressible for that reason. --mutate reuses the whole existing driver: the seed range sweeps the candidate node index, and subprocess isolation, --threads and the crash/verifier/timeout classification are unchanged. --mutate-dir sweeps a corpus and calibrates each file; --mut-stride samples it; --mut-count applies several edits at once and skips calibration; --mut-depth bounds the nesting kinds; --synth-skip now applies to every generator. An assertion banner counts as a crash, victim and log names are per-run, and a mutation timeout is timed against the wrapper. Co-Authored-By: Claude Opus 5 (1M context) --- utils/internal/ast-fuzz/README.md | 29 +- utils/internal/ast-fuzz/_ast_synth.das | 355 ++++++++++++++++++++++++- utils/internal/ast-fuzz/main.das | 166 +++++++++++- 3 files changed, 538 insertions(+), 12 deletions(-) diff --git a/utils/internal/ast-fuzz/README.md b/utils/internal/ast-fuzz/README.md index 30740659ce..c834c9c654 100644 --- a/utils/internal/ast-fuzz/README.md +++ b/utils/internal/ast-fuzz/README.md @@ -134,7 +134,34 @@ A run is `CRASH` only on a definitive abnormal exit (a `CRASH:` banner, signal, or abort). A **timeout** is reported separately, because it is ambiguous: a real hang *or* just a slow compile. Re-run with a larger `--timeout` to tell them apart. Hitting the `--memcap` ceiling is reported as `resource`: unbounded -allocation on a pathological input is not a compiler crash. +allocation on a pathological input is not a compiler crash. A timeout keeps a +`__slow.seed.das` repro, the same way a crash keeps `__crash.seed.das`. + +## Mutation of real programs + +`--mutate ` / `--mutate-dir ` re-infers an existing program with edits +applied to its AST during inference. Every edit is a shape source could also +spell, so a crash from one is a compiler bug rather than a malformed-tree report. + +`--mut-kind K` applies one kind at node `--mut-at`; `--mut-count N` applies N +random edits per run. Kinds: 0 op2 operator, 1 int literal, 2 call argument +order, 3 variable name, 4 let type, 5 let type to `auto`, 6 let const, 7 let type +wrapped in `array`, 8 copy to move/clone, 9 statement into unsafe/try/scope, 10 +statement into a closure, 11 statement duplicated, 12 argument const, 13 default +value on the last argument, 14 result type to `auto`, 15 let type nested +`--mut-depth` deep, 16 statement nested `--mut-depth` closures deep. + +A timeout in this mode is measured against the **wrapper**, not the file. Requiring the +macro module costs about a second in a Release build and about ten in a Debug one, +before any edit is applied, so a bare-file baseline makes every slow victim look like a +compile-time blowup. Time a no-op wrapper compile of the same file first; only the +excess over that is the mutation's. + +Two properties keep this honest. `--verify` must report **zero** +verifier-caught runs: one means the mutator is building trees source cannot +produce, not programs. And only the module being compiled can be mutated - a +required module is already inferred when the macro runs, so edits to it are +never re-checked and every run comes back clean. ## Source probes (`probe.das`) diff --git a/utils/internal/ast-fuzz/_ast_synth.das b/utils/internal/ast-fuzz/_ast_synth.das index 50ad7f965f..aca4476cb2 100644 --- a/utils/internal/ast-fuzz/_ast_synth.das +++ b/utils/internal/ast-fuzz/_ast_synth.das @@ -15,6 +15,12 @@ require strings require daslib/strings_boost var private g_synthed = false +// --synth-skip, applied to every generator instance. Filling it at one creation site only +// leaves the flag silently inert everywhere else, which makes a bisect report the wrong kind. +var private g_skip_kinds = "" +// How many levels the nesting kinds (15, 16) pile on. Legal source can nest this deep, so +// a compiler that descends recursively has to survive it. +var private g_mut_depth = 8 let private HANDLED_TYPES = fixed_array( "$:das_string", "$:clock", "$:HashBuilder", @@ -33,6 +39,14 @@ struct private DeclPool { @do_not_delete funcs : array } +def private apply_skip(var g : GenAll?) : void { + for (sk in split(g_skip_kinds, ",")) { + if (!empty(strip(sk))) { + g.skip[strip(sk)] := true + } + } +} + def private harvest_annotations(var pool : DeclPool) : void { for (spec in HANDLED_TYPES) { let c = find(spec, ":") @@ -53,6 +67,7 @@ def private harvest_annotations(var pool : DeclPool) : void { def private add_synth_decls(mod : Module?; var pool : DeclPool; seedInt : int; count : int; depth : int) : void { var g = new GenAll(seed = random_seed(seedInt * 131 + 7), max_depth = depth, at = LineInfo()) + apply_skip(g) pool.structs |> reserve(count) pool.enums |> reserve(count) pool.vars |> reserve(count) @@ -246,11 +261,13 @@ def private add_synth_typed_functions(mod : Module?; seedInt : int; count, depth var added = add_generic_passthrough(mod, seedInt, at, known) var decls = new GenAll(seed = random_seed(seedInt * 131 + 7), max_depth = depth, run_mode = run_mode, at = at) + apply_skip(decls) add_typed_structs(mod, decls, seedInt, 1 + abs(random_int(decls.seed)) % 3) added += add_piped_helpers(mod, decls, seedInt, at) for (i in range(count)) { var g = new GenAll(seed = random_seed(seedInt + i * 7919), max_depth = depth, run_mode = run_mode, at = at) + apply_skip(g) g.typed_fns |> push_clone_from(known) g.ts_ptr |> push_from(decls.ts_ptr) g.ts_fields |> push_clone_from(decls.ts_fields) @@ -296,6 +313,7 @@ def private add_synth_functions(mod : Module?; seedInt : int; count : int; depth for (i in range(count)) { var g = new GenAll(seed = random_seed(seedInt + i * 6151), max_depth = depth, at = LineInfo()) + apply_skip(g) let nargs = abs(random_int(seed)) % 3 // the body is replaced with random statements below, so a non-void result is // error[30310] every time and the program never reaches codegen @@ -318,6 +336,314 @@ def private add_synth_functions(mod : Module?; seedInt : int; count : int; depth return added } +// Mutation of a REAL program's AST, as opposed to synthesis from nothing. Every edit is +// one a person could have typed - a different operator, a different literal, a swapped +// argument, another declared type - so a crash it provokes is a compiler bug rather than +// a shape only a macro can build. One deterministic edit per run (the Nth candidate node +// gets kind K), which makes a finding reproducible and already close to minimal. +let private MUT_OPS = fixed_array("+", "-", "*", "%", "==", "!=", "<", ">", "<=", ">=", + "&&", "||", "&", "|", "^", "<<", ">>") + +class private Mutator : AstVisitor { + at_target : int = -1 + kind : int = 0 + idx : int = 0 + applied : string + seed : int4 + + def pick(n : int) : int { + return n <= 1 ? 0 : abs(random_int(seed)) % n + } + + def hit() : bool { + let me = idx + idx++ + return me == at_target + } + + def override visitExprOp2(var expr : ExprOp2?) : ExpressionPtr { + if (kind == 0 && hit()) { + let was = "{expr.op}" + expr.op := MUT_OPS[pick(length(MUT_OPS))] + applied = "op2 {was} -> {expr.op}" + } + return expr + } + + def override visitExprConstInt(var expr : ExprConstInt?) : ExpressionPtr { + if (kind == 1 && hit()) { + let was = expr.value + expr.value = pick(3) == 0 ? 0 : (pick(2) == 0 ? -1 : 2147483647) + applied = "int {was} -> {expr.value}" + } + return expr + } + + def override visitExprCall(var expr : ExprCall?) : ExpressionPtr { + if (kind == 2 && hit() && length(expr.arguments) >= 2) { + var a <- expr.arguments[0] + var b <- expr.arguments[1] + expr.arguments[0] <- b + expr.arguments[1] <- a + applied = "swapped first two arguments of {expr.name}" + } + return expr + } + + // 8: the same assignment written as a move, or as a clone + def override visitExprCopy(var expr : ExprCopy?) : ExpressionPtr { + if (kind == 8 && hit()) { + var l <- expr.left + var r <- expr.right + if (pick(2) == 0) { + applied = "copy -> move" + return new ExprMove(at = expr.at, op := "<-", left = l, right = r) + } + applied = "copy -> clone" + return new ExprClone(at = expr.at, op := ":=", left = l, right = r) + } + return expr + } + + // 9: put one statement inside a construct that changes nothing at runtime but does + // change the context inference sees - unsafe, try/recover, or a bare scope + // 10: run one statement through a closure instead of inline + def override visitExprBlock(var expr : ExprBlock?) : ExpressionPtr { + if (kind == 9 && hit() && !empty(expr.list)) { + let at = pick(length(expr.list)) + var inner = new ExprBlock(at = expr.at) + var stmt <- expr.list[at] + inner.list |> emplace(stmt) + let form = pick(3) + if (form == 0) { + applied = "statement wrapped in unsafe" + expr.list[at] = new ExprUnsafe(at = expr.at, body = inner) + } elif (form == 1) { + applied = "statement wrapped in try/recover" + expr.list[at] = new ExprTryCatch(at = expr.at, try_block = inner, + catch_block = new ExprBlock(at = expr.at)) + } else { + applied = "statement wrapped in a bare scope" + expr.list[at] = inner + } + } + if (kind == 10 && hit() && !empty(expr.list)) { + let at = pick(length(expr.list)) + var body = new ExprBlock(at = expr.at) + body.blockFlags.isClosure = true + body.returnType = new TypeDecl(at = expr.at, baseType = Type.tVoid) + var stmt <- expr.list[at] + body.list |> emplace(stmt) + var mb = new ExprMakeBlock(at = expr.at, _block = body) + var inv = new ExprInvoke(at = expr.at, name := "invoke") + inv.arguments |> emplace(mb) + expr.list[at] = inv + applied = "statement moved into an invoked closure" + } + // 16: the same statement, but reached through g_mut_depth nested closures + if (kind == 16 && hit() && !empty(expr.list)) { + let at = pick(length(expr.list)) + var stmt <- expr.list[at] + for (_i in range(g_mut_depth)) { + var body = new ExprBlock(at = expr.at) + body.blockFlags.isClosure = true + body.returnType = new TypeDecl(at = expr.at, baseType = Type.tVoid) + body.list |> emplace(stmt) + var inv = new ExprInvoke(at = expr.at, name := "invoke") + var mb = new ExprMakeBlock(at = expr.at, _block = body) + inv.arguments |> emplace(mb) + stmt <- inv + } + expr.list[at] <- stmt + applied = "statement nested in {g_mut_depth} closures" + } + if (kind == 11 && hit() && !empty(expr.list)) { + let at = pick(length(expr.list)) + var dup = clone_expression(expr.list[at]) + expr.list |> emplace(dup, at + 1) + applied = "statement duplicated" + } + return expr + } + + def override visitExprVar(var expr : ExprVar?) : ExpressionPtr { + if (kind == 3 && hit()) { + let was = "{expr.name}" + expr.name := "{was}_" + expr.variable = null + expr.varFlags = ExprVarFlags(0) + applied = "var {was} -> {expr.name}" + } + return expr + } + + // 5: drop the declared type and make inference derive it + // 6: same declaration, but const + // 7: same declaration, but a container of the old type + def override visitExprLetVariable(var expr : ExprLet?; var arg : VariablePtr; + lastArg : bool) : VariablePtr { + if (kind == 5 && hit() && arg._type != null && arg.init != null) { + applied = "let type {describe(arg._type)} -> auto" + arg._type = new TypeDecl(at = arg.at, baseType = Type.autoinfer) + return arg + } + if (kind == 6 && hit() && arg._type != null) { + applied = "let {describe(arg._type)} -> const" + arg._type.flags.constant = true + return arg + } + if (kind == 7 && hit() && arg._type != null) { + let was = describe(arg._type) + var outer = new TypeDecl(at = arg.at, baseType = Type.tArray) + outer.firstType = clone_type(arg._type) + arg._type = outer + applied = "let type {was} -> array of it" + return arg + } + // 15: the same declaration, but nested g_mut_depth containers deep. Legal source + // can spell this, so a recursive descent over the type has to survive it. + if (kind == 15 && hit() && arg._type != null && arg.init == null) { + let was = describe(arg._type) + for (_i in range(g_mut_depth)) { + var outer = new TypeDecl(at = arg.at, baseType = Type.tArray) + outer.firstType = clone_type(arg._type) + arg._type = outer + } + applied = "let type {was} -> array nested {g_mut_depth} deep" + return arg + } + if (kind == 4 && hit() && arg._type != null) { + let was = describe(arg._type) + var g = new GenAll(seed = seed, max_depth = 2, at = arg.at) + apply_skip(g) + arg._type = g.ty_decl(VALUE_CODES[pick(length(VALUE_CODES))]) + let now = describe(arg._type) + unsafe { + delete g + } + applied = "let type {was} -> {now}" + } + return arg + } + + // 12: same argument, but const + // 13: the last argument gains a default value + def override visitFunctionArgument(var fun : FunctionPtr; var arg : VariablePtr; + lastArg : bool) : VariablePtr { + if (kind == 12 && hit() && arg._type != null) { + applied = "argument {arg.name} {describe(arg._type)} -> const" + arg._type.flags.constant = true + return arg + } + if (kind == 13 && hit() && lastArg && arg.init == null && arg._type != null && + !arg._type.flags.ref) { + let bt = arg._type.baseType + if (bt == Type.tInt) { + arg.init := new ExprConstInt(at = arg.at, value = 0) + applied = "argument {arg.name} default = 0" + } elif (bt == Type.tFloat) { + arg.init := new ExprConstFloat(at = arg.at, value = 0.0) + applied = "argument {arg.name} default = 0." + } elif (bt == Type.tBool) { + arg.init := new ExprConstBool(at = arg.at, value = false) + applied = "argument {arg.name} default = false" + } elif (bt == Type.tString) { + arg.init := new ExprConstString(at = arg.at, value := "") + applied = "argument {arg.name} default = empty string" + } + } + return arg + } + + // 14: the result type is dropped, so inference has to derive it from the body + def override visitFunction(var fun : FunctionPtr) : FunctionPtr { + if (kind == 14 && hit() && fun.result != null && + fun.result.baseType != Type.autoinfer) { + applied = "result type {describe(fun.result)} -> auto" + fun.result = new TypeDecl(at = fun.at, baseType = Type.autoinfer) + } + return fun + } +} + +// One pass that mutates nothing, to learn how many candidates of this kind exist. +def private count_kind(prog : ProgramPtr; mod : Module?; kind : int) : int { + var m = new Mutator(at_target = -1, kind = kind, seed = random_seed(1)) + make_visitor(*m) $(adapter) { + visit_module(prog, adapter, mod) + } + let n = m.idx + unsafe { + delete m + } + return n +} + +def private apply_one(prog : ProgramPtr; mod : Module?; at_target, kind : int; + seed : int4; var what : string&) : bool { + var m = new Mutator(at_target = at_target, kind = kind, seed = seed) + make_visitor(*m) $(adapter) { + visit_module(prog, adapter, mod) + } + what = m.applied + unsafe { + delete m + } + return !empty(what) +} + +// Several edits at once. Each one on its own is something a person could type, but the +// combination puts inference in a state no single edit reaches - which is where a +// well-formed program still has a chance of breaking it. +def private mutate_module_many(prog : ProgramPtr; mod : Module?; seedInt, count, + nkinds : int) : bool { + var seed = random_seed(seedInt * 104729 + 7) + var applied = 0 + for (i in range(count)) { + let kind = abs(random_int(seed)) % nkinds + let n = count_kind(prog, mod, kind) + if (n < 1) { + continue + } + let at = abs(random_int(seed)) % n + var what : string + if (apply_one(prog, mod, at, kind, seed, what)) { + applied++ + to_log(LOG_ERROR, "AST-MUT[{i}]: kind={kind} at={at}/{n}: {what}\n") + } + } + to_log(LOG_ERROR, "AST-MUT: {applied} edit(s) applied\n") + if (applied == 0) { + return false + } + for_each_function(mod, "") $(var fn) { + fn.not_inferred() + } + return true +} + +def private mutate_module(prog : ProgramPtr; mod : Module?; seedInt, at_target, + kind : int) : bool { + var m = new Mutator(at_target = at_target, kind = kind, + seed = random_seed(seedInt * 7919 + 13)) + make_visitor(*m) $(adapter) { + visit_module(prog, adapter, mod) + } + let nodes = m.idx + let what = m.applied + unsafe { + delete m + } + to_log(LOG_ERROR, "AST-MUT: kind={kind} at={at_target} of {nodes} candidate(s): {empty(what) ? "nothing" : what}\n") + if (empty(what)) { + return false + } + for_each_function(mod, "") $(var fn) { + fn.not_inferred() + } + return true +} + [infer_macro] class AstSynthMacro : AstPassMacro { def override apply(prog : ProgramPtr; mod : Module?) : bool { // nolint:STYLE038 — dispatch over node kinds; splitting hides the table @@ -329,7 +655,7 @@ class AstSynthMacro : AstPassMacro { let size = prog._options |> find_arg("_ast_synth_size") ?as tInt ?? 12 let depth = prog._options |> find_arg("_ast_synth_depth") ?as tInt ?? 4 let prune = prog._options |> find_arg("_ast_synth_prune") ?as tInt ?? -1 - let skip_kinds = prog._options |> find_arg("_ast_synth_skip") ?as tString ?? "" + g_skip_kinds = prog._options |> find_arg("_ast_synth_skip") ?as tString ?? "" let rand_flags = prog._options |> find_arg("_ast_synth_rflags") ?as tBool ?? false let report = prog._options |> find_arg("_ast_synth_report") ?as tBool ?? false let dump = prog._options |> find_arg("_ast_synth_dump") ?as tBool ?? false @@ -342,6 +668,27 @@ class AstSynthMacro : AstPassMacro { let ndecls = prog._options |> find_arg("_ast_synth_decls") ?as tInt ?? 0 let bind_refs = prog._options |> find_arg("_ast_synth_bind") ?as tBool ?? false let pre_typed = prog._options |> find_arg("_ast_synth_pretype") ?as tBool ?? false + let mutCount = prog._options |> find_arg("_ast_synth_mut_count") ?as tInt ?? 0 + g_mut_depth = prog._options |> find_arg("_ast_synth_mut_depth") ?as tInt ?? 8 + // Only the module being compiled can be mutated: a required module is already + // inferred by the time this macro runs, so edits to it are never re-checked. + var target : Module? + program_for_each_module(prog) $(m) { + if (empty(m.name)) { + target = m + } + } + if (target == null) { + return false + } + if (mutCount > 0) { + return mutate_module_many(prog, target, seedInt, mutCount, 17) + } + let mutAt = prog._options |> find_arg("_ast_synth_mut_at") ?as tInt ?? -1 + if (mutAt >= 0) { + let mutKind = prog._options |> find_arg("_ast_synth_mut_kind") ?as tInt ?? 0 + return mutate_module(prog, target, seedInt, mutAt, mutKind) + } let typed = prog._options |> find_arg("_ast_synth_typed") ?as tBool ?? false if (typed) { var vat = LineInfo() @@ -380,12 +727,8 @@ class AstSynthMacro : AstPassMacro { { var g = new GenAll(seed = random_seed(seedInt + n * 7919), max_depth = depth, rand_flags = rand_flags, at = fn.at) + apply_skip(g) fill_pools(g, pool, bind_refs, pre_typed) - for (sk in split(skip_kinds, ",")) { - if (!empty(strip(sk))) { - g.skip[strip(sk)] := true - } - } var blk = new ExprBlock(at = fn.at) for (_i in range(size)) { var st = g.gen_expr(0) diff --git a/utils/internal/ast-fuzz/main.das b/utils/internal/ast-fuzz/main.das index 484a75453b..18df0cf456 100644 --- a/utils/internal/ast-fuzz/main.das +++ b/utils/internal/ast-fuzz/main.das @@ -9,6 +9,9 @@ let FUZZ_DIR = "utils/internal/ast-fuzz" let ALL_PATH = "utils/internal/ast-fuzz/_ast_synth_all.das" var WRAP_PATH = "utils/internal/ast-fuzz/__wrap.das" var MEMCAP_KB = 4194304 +// Distinguishes concurrent sweeps in one tree. Sharing a victim or a worker log means each +// sweep reads the other's file, and every number both report is garbage. +var RUN_TOKEN = 0 [CommandLineArgs] struct Config { @@ -42,6 +45,10 @@ struct Config { @clarg_doc = "Worker id: gives this process its own wrapper file (__wrap.das). Set by --threads; only pass it by hand to run one worker of a split sweep" worker : int + @clarg_name = "run-token" + @clarg_doc = "Distinguishes one sweep's victim and log files from another's in the same tree; set automatically for workers" + run_token : int + @clarg_name = "synth-size" @clarg_doc = "Statements per synthesized body (default 12)" synth_size : int @@ -98,6 +105,38 @@ struct Config { @clarg_doc = "Print a per-node-kind histogram of what was generated" synth_report : bool + @clarg_name = "mutate" + @clarg_doc = "Mutate the AST of this real .das file instead of synthesizing one. The seed range sweeps the candidate node index, so --seeds N covers the first N nodes" + mutate : string + + @clarg_name = "mutate-dir" + @clarg_doc = "Mutate every .das in this directory: each file x each kind, with the candidate-node count measured per pair so the seed range covers exactly the nodes that exist" + mutate_dir : string + + @clarg_name = "mut-kinds" + @clarg_doc = "Which kinds --mutate-dir sweeps, comma separated (default 0,1,2,3,4)" + mut_kinds : string + + @clarg_name = "mut-count" + @clarg_doc = "Apply N edits at once instead of one, kinds and positions drawn from the seed. Each edit stays source-expressible; the combination is what a single edit cannot reach" + mut_count : int + + @clarg_name = "mut-stride" + @clarg_doc = "--mutate-dir takes every Nth file (default 1)" + mut_stride : int + + @clarg_name = "mut-cap" + @clarg_doc = "Never sweep more than this many nodes per file/kind (default 300)" + mut_cap : int + + @clarg_name = "mut-kind" + @clarg_doc = "Which AST edit to apply: 0 operator, 1 int literal, 2 swap two call arguments, 3 variable name, 4 declared type of a let, 5 let type to auto, 6 let const, 7 let type wrapped in array, 8 copy to move or clone, 9 statement into unsafe/try/scope, 10 statement into a closure, 11 statement duplicated, 12 argument const, 13 default value on the last argument, 14 result type to auto, 15 let type nested, 16 statement nested in closures (default 0)" + mut_kind : int + + @clarg_name = "mut-depth" + @clarg_doc = "How many levels the nesting kinds (15, 16) pile on (default 8)" + mut_depth : int + @clarg_doc = "Reproduce mode: compile this victim path once and report" file : string @@ -111,7 +150,31 @@ def read_text(path : string) : string { return fread(path) } +// Mutation victim: the real file, with the macro required in front of it and the node +// index carried in the options. Everything downstream - subprocess isolation, the +// crash/verifier/timeout classification, --threads - is the existing driver. +def build_mutation_victim(cfg : Config; seed : int) : string { + return build_string() $(var w) { + w |> write("options _enable_ast_synth = true\n") + if (cfg.mut_count > 0) { + w |> write("options _ast_synth_mut_count = {cfg.mut_count}\n") + } else { + w |> write("options _ast_synth_mut_at = {seed}\n") + w |> write("options _ast_synth_mut_kind = {cfg.mut_kind}\n") + } + w |> write("options _ast_synth_seed = {seed}\n") + if (cfg.mut_depth > 0) { + w |> write("options _ast_synth_mut_depth = {cfg.mut_depth}\n") + } + w |> write("require _ast_synth\n") + w |> write(read_text(cfg.mutate)) + } +} + def build_victim(cfg : Config; seed : int) : string { + if (!empty(cfg.mutate)) { + return build_mutation_victim(cfg, seed) + } return build_string() $(var w) { w |> write("options gen2\n") w |> write("options _enable_ast_synth = true\n") @@ -194,7 +257,10 @@ def is_crash(exit_code : int; out : string) : bool { if (exit_code >= 128 || exit_code < 0) { return true } - return find(out, "CRASH:") >= 0 || find(out, "Segmentation fault") >= 0 + // An assertions-enabled build reports an internal invariant violation and traps; the + // banner is what identifies it even if the trap itself is swallowed. + return (find(out, "CRASH:") >= 0 || find(out, "Segmentation fault") >= 0 + || find(out, "assertion failed") >= 0) } def ensure_generated(bin : string) : bool { @@ -246,6 +312,13 @@ def private child_flags(cfg : Config) : string { if (cfg.synth_typed) { w |> write(" --synth-typed") } + if (!empty(cfg.mutate)) { + w |> write(" --mutate {cfg.mutate}") + w |> write(" --mut-kind {cfg.mut_kind}") + if (cfg.mut_count > 0) { + w |> write(" --mut-count {cfg.mut_count}") + } + } if (cfg.synth_rflags) { w |> write(" --synth-rflags") } @@ -261,6 +334,7 @@ def private child_flags(cfg : Config) : string { if (cfg.timeout > 0.0) { w |> write(" --timeout {cfg.timeout}") } + w |> write(" --run-token {RUN_TOKEN}") } } @@ -272,7 +346,7 @@ def private run_threaded(cfg : Config; bin : string; threads, base, seeds : int; for (i in range(threads)) { w |> write("nice -n 15 '{bin}' {FUZZ_DIR}/main.das -- --bin '{bin}'") w |> write(" --worker {i + 1} --base-seed {base + i * per} --seeds {per}{flags}") - w |> write(" > {FUZZ_DIR}/__worker{i + 1}.log 2>&1 & ") + w |> write(" > {FUZZ_DIR}/__worker{RUN_TOKEN}_{i + 1}.log 2>&1 & ") } w |> write("wait") } @@ -286,7 +360,7 @@ def private run_threaded(cfg : Config; bin : string; threads, base, seeds : int; } var crashes = 0 for (i in range(threads)) { - let log = read_text("{FUZZ_DIR}/__worker{i + 1}.log") + let log = read_text("{FUZZ_DIR}/__worker{RUN_TOKEN}_{i + 1}.log") for (ln in split(log, "\n")) { if (find(ln, "run(s) —") >= 0) { to_log(LOG_INFO, " worker {i + 1}: {strip(slice(ln, find(ln, "run(s) —")))}\n") @@ -296,10 +370,81 @@ def private run_threaded(cfg : Config; bin : string; threads, base, seeds : int; } } } - to_log(LOG_INFO, "ast-fuzz: {crashes} crash(es) across {threads} worker(s); logs in {FUZZ_DIR}/__worker*.log\n") + to_log(LOG_INFO, "ast-fuzz: {crashes} crash(es) across {threads} worker(s); logs in {FUZZ_DIR}/__worker{RUN_TOKEN}_*.log\n") return crashes > 0 ? 1 : 0 } +// How many nodes of this kind the file actually has. The macro reports it, so one +// compile answers it - without this the sweep spends most of its seeds on indices that +// do not exist and every run comes back a no-op. +def private count_candidates(cfg : Config; bin : string; file : string; kind : int; + timeout : float) : int { + var probe = cfg + probe.mutate = file + probe.mut_kind = kind + if (!fwrite(WRAP_PATH, build_victim(probe, 0))) { + return 0 + } + var out : string + run_and_capture([bin, "-compile-only", WRAP_PATH], out, timeout) + let at = find(out, "of ") + if (at < 0) { + return 0 + } + let rest = slice(out, at + 3) + let sp = find(rest, " ") + return sp < 0 ? 0 : to_int(slice(rest, 0, sp)) +} + +def private run_mutation_sweep(cfg : Config; bin : string; timeout : float) : int { + var kinds : array + let spec = empty(cfg.mut_kinds) ? "0,1,2,3,4" : cfg.mut_kinds + for (k in split(spec, ",")) { + if (!empty(strip(k))) { + kinds |> push(to_int(strip(k))) + } + } + let cap = cfg.mut_cap > 0 ? cfg.mut_cap : 300 + var files : array + glob(cfg.mutate_dir, "*.das") $(filename, is_dir) { + if (!is_dir && !starts_with(base_name(filename), "_")) { + files |> push(path_join(cfg.mutate_dir, filename)) + } + } + files |> sort() + let stride = max(cfg.mut_stride, 1) + var picked <- [for (f, fi in files, count()); f; where fi % stride == 0] + to_log(LOG_INFO, "ast-fuzz: mutating {length(picked)} of {length(files)} file(s) x {length(kinds)} kind(s)\n") + var findings = 0 + for (f in picked) { + for (k in kinds) { + // in --mut-count mode every seed is a different combination of edits, so + // there is nothing to calibrate: the seed range is just how many to try + var n = cap + if (cfg.mut_count <= 0) { + n = min(count_candidates(cfg, bin, f, k, timeout), cap) + } + if (n < 1) { + continue + } + var c = cfg + c.mutate = f + c.mut_kind = k + c.seeds = n + c.base_seed = 1 + c.mutate_dir = "" + to_log(LOG_INFO, "ast-fuzz: {base_name(f)} kind={k} nodes={n}\n") + let th = cfg.threads > 1 && n >= cfg.threads ? cfg.threads : 1 + let rc = run_threaded(c, bin, th, 1, n, timeout) + if (rc == 1) { + findings++ + } + } + } + to_log(LOG_INFO, "ast-fuzz: mutation sweep done, {findings} file/kind pair(s) with findings\n") + return findings > 0 ? 1 : 0 +} + [export] def main : int { // nolint:STYLE037,STYLE038 — one linear sweep with a flat classification tally; splitting hides it var r <- parse_args(type) @@ -320,8 +465,11 @@ def main : int { // nolint:STYLE037,STYLE038 — one linear sweep with a flat cl if (cfg.memcap > 0) { MEMCAP_KB = cfg.memcap * 1024 } + RUN_TOKEN = cfg.run_token > 0 ? cfg.run_token : int(int64(get_clock()) % 99991) if (cfg.worker > 0) { - WRAP_PATH = "{FUZZ_DIR}/__wrap{cfg.worker}.das" + // The run token keeps two sweeps in one tree off each other's victims. Sharing one + // file means each sweep compiles the other's input and every number is garbage. + WRAP_PATH = "{FUZZ_DIR}/__wrap{RUN_TOKEN}_{cfg.worker}.das" } let timeout = cfg.timeout > 0.0 ? cfg.timeout : 20.0 @@ -340,6 +488,9 @@ def main : int { // nolint:STYLE037,STYLE038 — one linear sweep with a flat cl let seeds = cfg.seeds > 0 ? cfg.seeds : 20 let base = cfg.base_seed > 0 ? cfg.base_seed : 1 + if (!empty(cfg.mutate_dir)) { + return run_mutation_sweep(cfg, bin, timeout) + } if (cfg.threads > 1) { return run_threaded(cfg, bin, cfg.threads, base, seeds, timeout) } @@ -361,6 +512,11 @@ def main : int { // nolint:STYLE037,STYLE038 — one linear sweep with a flat cl let ec = run_wrapper(bin, cfg.verify, out, timeout) if (is_timeout(ec)) { nTimeout++ + // A mutant that outruns the timeout is the compile-time-blowup class, so it + // needs a repro on disk just as much as a crash does. + let slow = "{FUZZ_DIR}/__slow.seed{seed}.das" + fwrite(slow, read_text(WRAP_PATH)) + repros |> push("seed={seed} TIMEOUT repro={slow}") } elif (is_resource(out)) { nResource++ } elif (is_crash(ec, out)) { From c6cb39c77ef1d8092c00457348c334d13c8b42c7 Mon Sep 17 00:00:00 2001 From: Churkin Aleksey Date: Fri, 28 Aug 2026 10:13:00 +0300 Subject: [PATCH 4/4] ast_verify: report a base class used as a node [skip ci] ExprConst and ExprMakeLocal are bases a concrete node derives from. Neither overrides visit, so a walk that reaches one asserts inside Expression::visit on an assertions-enabled build, with no diagnostic - and the walk it kills includes this verifier's own. The scan therefore reads the module's gc list instead of descending, and returns before the visitor starts. All three entry points check: verify_module, verify_module_after_infer, and verify_function, which has no module of its own and asks the program being compiled for one. The pair is what a macro can build and a visitor cannot walk, measured rather than read off the header: of the 43 classes without their own visit, 39 inherit one and 2 cannot be constructed from daslang at all. Of 31 generator repros that asserted, 26 now report a located error. The rest reach the compiler's own Program::visit before any verifier entry runs. The make-pr ast-verify gate skips the ast-fuzz fixtures, which are deliberately malformed. Co-Authored-By: Claude Opus 5 (1M context) --- daslib/ast_verify.das | 38 +++++++++++++++++++ .../ast-fuzz/selftest/abstract_node.das | 9 +++++ .../selftest/abstract_node_breaker.das | 30 +++++++++++++++ utils/internal/ast-fuzz/test_ast_fuzz.das | 9 +++++ utils/internal/make-pr/main.das | 5 ++- 5 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 utils/internal/ast-fuzz/selftest/abstract_node.das create mode 100644 utils/internal/ast-fuzz/selftest/abstract_node_breaker.das diff --git a/daslib/ast_verify.das b/daslib/ast_verify.das index 9d98d2c64b..5cf20c4b8a 100644 --- a/daslib/ast_verify.das +++ b/daslib/ast_verify.das @@ -801,10 +801,41 @@ class private AstVerifyVisitor : AstVisitor { } } +// Neither base overrides visit, so a walk reaching one asserts in Expression::visit - this +// verifier's own walk included. Hence the gc list rather than a descent, and the early out. +def private verify_no_abstract_nodes(mod : Module?) : int { + var n = 0 + for_each_gc_expression(mod) $(e) { + if (e is ExprConst || e is ExprMakeLocal) { + n++ + let loc = describe(e.at) + let msg = "AST verify: {e.__rtti} is a base class, not a node - build a concrete subclass" + to_log(LOG_ERROR, empty(loc) ? "{msg}\n" : "{msg} at {loc}\n") + compiling_program() |> macro_sticky_error(e.at, msg) + } + } + return n +} + +// verify_function has no module to scan, so it asks the program being compiled for one. +def private verify_no_abstract_nodes_being_compiled() : int { + var total = 0 + program_for_each_module(compiling_program()) $(m) { + if (empty(m.name)) { + total += verify_no_abstract_nodes(m) + } + } + return total +} + def public verify_module(prog : ProgramPtr; mod : Module?) : int { //! Verifies one module in place, emitting a sticky compile error per violation and //! returning the count. Call it from a macro's ``apply()`` right after building //! AST — the repo's ast-fuzz suite README covers when each entry point applies. + let abstract_nodes = verify_no_abstract_nodes(mod) + if (abstract_nodes > 0) { + return abstract_nodes + } var v = new AstVerifyVisitor(check_unique = prog._options |> find_arg("_ast_verify_unique") ?as tBool ?? true) make_visitor(*v) $(adapter) { v.adapter = adapter @@ -838,6 +869,9 @@ def public verify_function(var fn : FunctionPtr) : int { //! Verifies one function IN PLACE, signature included. For a macro that BUILDS a //! function: add_function mangles the signature at once, so a malformed result or //! argument type crashes there, before [[verify_module]] could see it. + if (verify_no_abstract_nodes_being_compiled() > 0) { + return 1 + } var v = new AstVerifyVisitor() make_visitor(*v) $(adapter) { visit(fn, adapter) @@ -1356,6 +1390,10 @@ def public verify_module_after_infer(prog : ProgramPtr; mod : Module?) : int { //! Verifies one module is ready for codegen, returning the violation count. //! Separate from [[verify_module]]: a half-inferred tree legitimately violates //! these, so they can only be asserted here. + let abstract_nodes = verify_no_abstract_nodes(mod) + if (abstract_nodes > 0) { + return abstract_nodes + } var v = new AstPostInferVerifyVisitor() make_visitor(*v) $(adapter) { visit_module(prog, adapter, mod) diff --git a/utils/internal/ast-fuzz/selftest/abstract_node.das b/utils/internal/ast-fuzz/selftest/abstract_node.das new file mode 100644 index 0000000000..5f9bbf4024 --- /dev/null +++ b/utils/internal/ast-fuzz/selftest/abstract_node.das @@ -0,0 +1,9 @@ +options gen2 +// lint-skip-file: abstract_node_breaker corrupts the AST and verify_module reports it by design +require abstract_node_breaker + +[export] +def main { + var x = 1 + print("{x + x}\n") +} diff --git a/utils/internal/ast-fuzz/selftest/abstract_node_breaker.das b/utils/internal/ast-fuzz/selftest/abstract_node_breaker.das new file mode 100644 index 0000000000..35c7ef931e --- /dev/null +++ b/utils/internal/ast-fuzz/selftest/abstract_node_breaker.das @@ -0,0 +1,30 @@ +options gen2 + +module abstract_node_breaker shared + +// Self-test fixture: puts a bare ExprConst - the base a concrete constant derives from, +// which never overrides visit - into a body. Any walk that reaches it asserts inside +// Expression::visit, so the check has to find it without descending. + +require daslib/ast +require daslib/ast_boost +require daslib/ast_verify + +[infer_macro] +class AbstractNodeMacro : AstPassMacro { + def override apply(prog : ProgramPtr; mod : Module?) : bool { + if (mod == null || !empty(mod.name)) { + return false + } + for_each_function(mod, "main") $(var fn) { + if (fn.body == null || !(fn.body is ExprBlock)) { + return + } + var blk = fn.body as ExprBlock + var node = new ExprConst(at = fn.at) + blk.list |> emplace(node) + } + verify_module(prog, mod) + return false + } +} diff --git a/utils/internal/ast-fuzz/test_ast_fuzz.das b/utils/internal/ast-fuzz/test_ast_fuzz.das index c3c01a8b46..bc2baee8ea 100644 --- a/utils/internal/ast-fuzz/test_ast_fuzz.das +++ b/utils/internal/ast-fuzz/test_ast_fuzz.das @@ -33,6 +33,15 @@ def test_verifier_cuts_a_cycle(t : T?) { "did not report the cycle\n{out}") } +[test] +def test_verifier_reports_an_abstract_node(t : T?) { + var out : string + let ec = run([BIN, "utils/internal/ast-fuzz/selftest/abstract_node.das"], out, 60.0) + t |> success(!died(ec, out), "compile crashed (exit {ec})\n{out}") + t |> success(find(out, "is a base class, not a node") >= 0, + "did not report the abstract node\n{out}") +} + [test] def test_verifier_drops_a_null_vector_entry(t : T?) { var out : string diff --git a/utils/internal/make-pr/main.das b/utils/internal/make-pr/main.das index 79992c160c..e390081dbd 100644 --- a/utils/internal/make-pr/main.das +++ b/utils/internal/make-pr/main.das @@ -164,7 +164,10 @@ def private gate_ast_verify(daslang : string) : bool { to_log(LOG_INFO, "ast-verify: diff touches no macro/AST surface - skipped\n") return true } - var das_files <- changed_files("*.das") + // The ast-fuzz selftest fixtures break their own AST on purpose and assert the report + // that follows, so their reports are the fixture working, not a node built wrong. + var das_files <- [for (f in changed_files("*.das")); + f; where find(f, "utils/internal/ast-fuzz/selftest/") < 0] var subject = "{length(das_files)} changed files" if (empty(das_files)) { das_files |> push("tests/jit_tests/array.das")