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/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/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/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/ast-fuzz/README.md b/utils/internal/ast-fuzz/README.md index bc3aa7bb15..c834c9c654 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 @@ -93,7 +134,65 @@ 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`) + +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`) diff --git a/utils/internal/ast-fuzz/_ast_synth.das b/utils/internal/ast-fuzz/_ast_synth.das index d6b6f0498b..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) @@ -98,12 +113,207 @@ 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) + 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) + 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 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 @@ -126,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 @@ -137,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 @@ -150,6 +668,40 @@ 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() + 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) @@ -175,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/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..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 @@ -62,6 +69,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 @@ -94,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 @@ -107,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") @@ -130,6 +197,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 +221,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") @@ -183,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 { @@ -232,6 +309,16 @@ def private child_flags(cfg : Config) : string { if (cfg.synth_pretype) { w |> write(" --synth-pretype") } + 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") } @@ -247,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}") } } @@ -258,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") } @@ -272,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") @@ -282,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) @@ -306,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 @@ -326,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) } @@ -347,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)) { 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/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 d828d7ebf5..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 @@ -174,3 +183,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}") +} 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") 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