Commits

51951679be73ef438de1dd78fc34508c18b0e694
Switch branches/tags
All users
All time
April 2026
Su Mo Tu We Th Fr Sa
29 30 31 1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 1 2
3 4 5 6 7 8 9

Commits on April 15, 2026

  1. expand CLI driver: full flag set, info actions, afs alias, phase timer
    Sprint 32 foundation. Replaces the bare ~7-flag parser with the full
    sprint-spec surface and threads each flag through to where it has
    real effect.
    
    New Options fields cover: --std=fNN, -ffree-form/-ffixed-form,
    -fdefault-{integer,real}-8, -fimplicit-none, -frecursive, -fbackslash,
    -fmax-stack-var-size=N, -Wall/-Wextra/-Wpedantic/-Wdeprecated/-Werror/
    -Wno-name (unknown -Wfoo accepted silently per gfortran convention),
    -g (accepted, DWARF deferred to sprint 35), -v / --verbose,
    --time-report, --diagnostics-format={text,json},
    -fcheck=bounds / =all, -J <dir>, -L <dir>, -l<lib>, -rpath, -shared,
    -static, --emit-ast, --emit-tokens, --diagnostics-format=, plus
    --version / --help / -dumpversion / -V info actions and @file
    response-file expansion.
    
    Wired through:
    - --std= → validate_file_with_layouts(.., opts.std, ..)
    - -ffree-form / -ffixed-form → source-form override before lex
    - -J → .amod write directory (default still parent of -o output)
    - -L / -l / -rpath / -shared / -static → push_link_flags() for both
      link() and link_multi()
    - -v → eprintln'd phase markers
    - --time-report → PhaseTimer collects per-phase durations and prints
      a Phase/Time(ms)/% table at end of compile()
    - -Werror → promotes warning diagnostics to fatal in compile()
    
    The  binary alias now lives at src/bin/afs.rs as a one-line
    wrapper that calls into a new shared armfortas::cli_entry() (so
    cargo doesn't warn about a duplicated bin source path). Both
    binaries build from the same logic and pass --version / --help.
    
    tests/cli_driver.rs exercises 14 user-visible behaviours via
    subprocess invocation: --version output, --help, -dumpversion,
    afs alias, no-arg help-to-stderr, -c, -S, -E (with macro), --std=f95
    rejecting ERROR STOP, response file, -J directory, -v phase
    streaming, --time-report table, missing-input → exit 3.
    mfwolffe committed
  2. lift i128/integer(16) backend codegen out of deferred status
    Two narrow blockers stood between the existing i128 codegen and the
    15 ignored test cases.
    
    The i128_backend_o0_supported gate rejected any Load whose result
    type was Ptr(Int(I128)) because type_contains_i128 walks through
    Ptr<>. But the loaded value is a normal 64-bit pointer and the
    wide-slot machinery is uninvolved — only the value type itself
    matters. Add a Load arm that always accepts pointer-typed loads.
    
    The collect_host_references walker built sub_locals from a contained
    proc's args + decls but skipped the function's result(r) clause
    name. A program with integer(16) :: r at host scope and a contained
    function 'function add5(...) result(r)' then captured its OWN result
    variable as a host-ref. The lowering appended a trailing ptr<i128>
    closure-passing arg, and the function body faithfully wrote its sum
    through that pointer instead of into the local result alloca — so
    the i128 return value was uninitialised garbage. Seed sub_locals
    with the implicit subprogram name and any result-clause name.
    
    After both fixes, all four i128_stack_args tests, all five
    i128_formatted_read tests, and all six i128_internal_io tests pass.
    The 'backend does not yet support integer(16)' driver gate no longer
    trips for any of them.
    mfwolffe committed
  3. mfwolffe committed
  4. mfwolffe committed
  5. lower derived-type function results used inline as a component base
    Harvesting the auditor's amod/derived_fn_noassign.f90 surfaced a new
    bug: 'add_t(a, b)%x' inline (i.e. without first assigning the call to
    a variable) printed zero. resolve_component_base only handled
    Expr::Name and Expr::ComponentAccess bases; a FunctionCall base fell
    through and the ComponentAccess arm reached its const_int(0)
    fallback. Add a callee_return_derived_type_name helper that walks
    the symbol table for the callee's result-variable derived type, and
    extend the ComponentAccess arm in lower_expr_full to lower the call
    when the base is a function returning a derived type and use its
    result pointer as the component base.
    
    Also lift four canonical brutal tests out of the auditor's scratch:
    - derived_fn_inline (the inline-component-of-function-result case)
    - op_single (cross-module operator(+) on derived type)
    - assn_cross_module (cross-module assignment(=))
    - final_cross_module (BLOCK-scope finalizer found via cross-module use)
    mfwolffe committed
  6. mfwolffe committed
  7. track per-field ALLOCATABLE/POINTER/TARGET on derived-type layouts
    The sprint 31 cluster fix for ALLOCATE-on-component and
    pointer-assign-on-component (findings 8-11) short-circuited the
    attribute check whenever the target selected into a component,
    because FieldLayout didn't carry per-component attributes and the
    base variable's attribute was the wrong thing to check. That was a
    half-fix: a malformed 'allocate(obj%not_alloc_field)' or
    'obj%plain_field => x' would compile silently and either miscompile
    or blow up at runtime. Complete it properly.
    
    - FieldLayout gains allocatable / pointer / target bools populated
      from the component decl's attrs.
    - .amod writer/reader emit '@allocatable' / '@pointer' / '@target'
      suffix flags so cross-TU consumers see the same metadata;
      backward-compatible with older files lacking the flags.
    - validate_file_with_layouts threads the registry into Ctx.
    - New leaf_field_layout helper walks a ComponentAccess chain down
      to its terminal field and also returns whether any ancestor on
      the path carries TARGET / ALLOCATABLE, so F2018 §8.5.14
      (subobject of a TARGET is itself a valid target) is honoured for
      pointer-assign-source checks.
    - validate_allocatable_item and validate_pointer_assignment now
      check the leaf's own attributes (with ancestor fallback for the
      source-must-be-target rule) instead of skipping outright.
    - Two new ERROR_EXPECTED negative tests exercise the ALLOCATE and
      pointer-assignment LHS paths so regressions in the per-field
      plumbing surface immediately.
    mfwolffe committed
  8. honor IMPLICIT statements scoped to a BLOCK construct
    F2018 §11.1.4 gives a BLOCK construct its own implicit-typing
    environment.  An outer scope that is IMPLICIT NONE plus a
    block-local 'implicit integer (i-n)' should let i..n be
    implicitly typed inside the block, but the AST dropped the
    parsed IMPLICIT decls, the IMPLICIT NONE walker treated the
    block body as an extension of the outer scope, and the lowerer
    never allocated implicitly-typed locals.
    
    - Stmt::Block carries an 'implicit: Vec<SpannedDecl>' field,
      populated by the parser instead of being discarded.
    - check_implicit_none / walk_stmt_for_undeclared / check_expr_names
      thread an 'implicit_letters' set through the walk; the BLOCK arm
      layers in the local letters (and an inner IMPLICIT NONE clears
      the inherited set per the standard).
    - The Stmt::Block lowering pre-walks the body, gathers any names
      it references that aren't already declared and whose first
      letter falls in a covered range, and synthesises TypeDecls so
      alloc_decls / init_decls allocate them with the implicit type.
    - New collect_referenced_names visitor walks expressions across
      the body's statement forms.
    
    Removes the XFAIL on test_programs/audit31_brutal_implicit_block.f90.
    mfwolffe committed
  9. name temp .s/.o by FNV hash of output path, not pid
    The previous fix used PID in the temp basename, but each
    compile_binary call from a parallel test spawns a fresh subprocess
    with a different PID, so two back-to-back compiles of the same
    output path embedded different /tmp/armfortas_<pid>.o strings into
    the linked binary's OSO debug stab — breaking
    linked_binary_is_deterministic_for_same_output_path_and_has_no_uuid.
    
    Stripping PID entirely fixed determinism but reintroduced the
    parallel-multifile race because two tests compiling files with the
    same basename to different unique-dir outputs collided on
    /tmp/armfortas_<stem>.s/.o.
    
    Use a stable FNV-1a 64 hash of the full output path: same path →
    same temp .o (deterministic across subprocess invocations), and
    different paths → different temp .o (parallel-safe).
    DefaultHasher's per-process random seed would have defeated (1).
    mfwolffe committed
  10. lower SUBMODULE units so separate-module procedures emit code
    A submodule providing implementations for its parent module's
    interface block parsed cleanly but produced nothing in the object
    file: lower_unit's ProgramUnit::Submodule arm fell through to the
    catch-all and never visited contains.  Linker reported 'Undefined
    symbols: _compute' for any program that called the function the
    submodule was meant to implement.
    
    - lower_unit::ProgramUnit::Submodule now mirrors the Module arm,
      using the parent module's name as the host for the contained
      procedures.
    - Pass 1 (collect_module_globals) installs submodule decls under
      the parent module's name so contains procs resolve them through
      the same global lookup the parent uses.
    - collect_internal_func_names, collect_alloc_return_funcs,
      collect_char_len_star_params, collect_optional_params,
      collect_descriptor_params, collect_elemental_funcs, and
      walk_contained_host_refs_inner all now descend into Submodule
      contains.
    mfwolffe committed
  11. join continuation lines whose trailing & sits inside an open string
    The macro expander runs per-line and rediscovers in-string state
    each call. If the trailing & sat outside any string,
    find_code_trailing_ampersand caught it and the preprocessor joined
    the lines before expansion; if the & sat inside an unterminated
    string, the function returned None, so line N+1 was expanded fresh
    and a leading ! was treated as a Fortran comment — chewing through
    the closing quote and producing 'unterminated string literal'.
    Sprint 31 #470 fixed the single-line counterpart; this finishes the
    multi-line case. Extend the scanner to track potential trailing & 's
    seen while in_string is still set.
    mfwolffe committed
  12. give every driver invocation its own /tmp/armfortas_<pid>_<n>.s/.o
    The driver wrote intermediate assembly and object files to
    /tmp/armfortas_<pid>.s and .o using just the PID. cargo test runs
    multiple test threads inside one binary, so two compilations from the
    same process collided on the same temp paths — one thread's link
    could pick up another thread's stale .o. Surfaced as an intermittent
    "linked binary should be byte-identical" failure on CI under load.
    Add a per-process atomic counter to the temp-file basenames.
    mfwolffe committed
  13. fire FINAL subroutines and implicit deallocation at END BLOCK
    F2018 §7.5.6.3 / §9.7.3.2: derived-type vars declared in a BLOCK must
    have their FINAL procs called, and block-scoped allocatables must be
    deallocated, when control leaves the block. The Stmt::Block lowering
    just restored shadowed outer locals and returned, so program-scope
    finalization worked but block-scope didn't. Gather the keys the block
    newly introduced and run insert_implicit_dealloc on them before the
    restore step.
    mfwolffe committed
  14. fix derived-type function result and assumed-shape function arg lowering
    Finding 5: function foo() result(r) with 'type(t) :: r' in body left
    the result var without a derived_type tag because the lowering only
    looked at the header's explicit return type. New
    derived_type_name_for_result_var checks both the header and the
    body's result-var decl so the result allocates a struct buffer and
    component assignments land on it.
    
    Finding 6: lower_expr_full did not consult the descriptor_params
    mask (only Stmt::Call did), so FUNCTION calls taking xs(:) got raw
    pointers and size(xs) read zero. Thread descriptor_params as an
    optional arg through lower_expr_full and emit lower_arg_descriptor
    for any callee param flagged as descriptor-backed.
    mfwolffe committed

Commits on April 14, 2026

  1. propagate nested CONTAINS host refs through the ancestor chain
    audit31 Finding 4: `inner` inside `outer` inside `program`
    lost its write to `host_var` because the closure-passing walker
    only collected refs against the IMMEDIATE host. Two missing pieces:
    
    1. walk_contained_host_refs only saw `outer.decls` when analysing
       `inner`, never the program's decls. Refactor to thread an
       accumulated ancestor chain and try each layer against
       collect_host_references — the nested proc's refs list now
       includes every host-var it needs, regardless of depth.
    
    2. The intermediate level (`outer`) had no refs of its own, so
       at the outer → inner call site `append_host_closure_args` had
       nothing to forward and pushed a NULL for host_var. Fold each
       proc's nested-contained refs into its own (filtered to names
       that live in an ancestor scope, to avoid pulling in
       outer-local names). `outer` now carries host_var as a hidden
       param, receives it from the program, and forwards it to inner.
    
    3. lower_unit's contains loop now passes `decls + inherited
       host_decls` as the child's host_decls so
       build_host_ref_params can resolve the host-ref type two
       (or more) scopes up.
    
    Reproducer: test_programs/audit31_brutal_nested_host.f90 prints
    `final host_var= 20` (was 10). Task #485.
    mfwolffe committed
  2. coerce to real Bool; unify operand widths in iand/ior/ieor
    Two audit31 findings, both caught by the tightened IR verifier:
    
    Finding 13 — .and./.or. on a derived-type LOGICAL component
    tripped "operands must be Bool (i8 / bool)". The field loads as
    i8 (its stored byte), and coerce_to_type's Int→Bool arm was
    emitting `int_trunc` which produces a value of type i8, not Bool.
    Rework Int→Bool to widen (if needed) and `icmp ne 0` so the
    result actually IS a Bool.
    
    Finding 14 — `iand(val_c_long, int(z'400', c_int))` tripped
    "bitwise op: operand width mismatch i64 vs i32". F2018
    §16.9.104 allows iand/ior/ieor across integer kinds; the verifier
    (rightly) doesn't. Add unify_int_widths that sign-extends the
    narrower operand to the wider, and route iand/ior/ieor through
    it before calling bit_and/or/xor.
    
    Verified: fortsh/src/common/memory_profiler.f90 now compiles.
    Tests: audit31_brutal_logical_i8.f90 (CHECK T),
    audit31_brutal_mixed_kind_bitwise.f90 (CHECK 768).
    Tasks #494, #495.
    mfwolffe committed
  3. pick IntExtend opcode by source width, add SXTB/SXTH
    IntExtend isel unconditionally emitted SXTW Xd, Wn regardless of
    source width or dest class. For any instruction whose target type
    was i32 or narrower, dest landed in Gp32 (a W register) and the
    assembler rightly rejected `sxtw Wd, Wn` — SXTW's dest must be
    64-bit. The audit (Finding 12) hit this on fortsh io/suggestions.f90
    and parsing/lexer.f90.
    
    Rework the InstKind::IntExtend arm:
    - Look up the source IR type via func.value_type to pick
      SXTB/SXTH/SXTW (matching source byte width), or MOV for the
      same-width and narrowing-disguised-as-extend cases that the
      upstream passes sometimes produce.
    - Force dest register class to Gp64 when widening to i64 so
      SXTW always gets its required X-register dest; otherwise
      follow the declared target type.
    
    Add Sxth and Sxtb opcodes to mir + emit. All three sign-extends
    take identical two-operand syntax.
    
    Verified: fortsh/src/io/suggestions.f90 now compiles to an object.
    Task #493.
    mfwolffe committed
  4. skip base-name attribute check when ALLOCATE / => walks a component
    Cluster-fix for audit31 findings 8, 9, 10: three validate_*
    checks that all ran extract_base_name on their target, then
    asserted the BASE carried the pointer/allocatable attribute. For
    component-access targets (`pools(i)%tokens(n)`, `ref%data =>`,
    `cb => proc`), the real attribute lives on the final component
    (or the dummy procedure), not on the base — the validators
    reported the wrong entity and rejected valid code.
    
    We don't track per-field attributes in the sema type registry
    today, so in the short term the validators skip the base-name
    check whenever the expression selects into a component. The
    lowering path still requires the leaf to be allocatable/pointer
    (bad code surfaces as a verifier or runtime error instead of a
    silent miscompile). For the proc-ptr case, allow symbols that
    carry attrs.external through (that's how
    `procedure(iface) :: proc` parses).
    
    Also drops an unused enumerate() index in install_runtime_dim_bounds
    that clippy flagged after the Finding 2 fix.
    
    Reproducers: audit31_brutal_alloc_component, audit31_brutal_ptr_substr,
    audit31_brutal_proc_ptr_dummy — all three now compile and print
    'ok'. Tasks #489, #490, #491.
    mfwolffe committed
  5. reorder keyword call arguments to match callee param order
    `call sub(b=10, a=20)` bound positionally — a=10, b=20 — because
    both Stmt::Call and Expr::FunctionCall iterated the actual arg list
    verbatim and ignored the `keyword` field. Silent wrong-value bug
    affecting any fortsh file that uses keyword args (variables,
    readline, printf_builtin).
    
    Add reorder_args_by_keyword: fast path when no keyword is present;
    otherwise look up the callee's arg_order from the symbol table and
    place each actual into its matching slot — positional actuals fill
    0..K, then keyword actuals index by name. Unknown keywords append
    to preserve error locality so sema still reports them.
    
    Call the reorderer right before arg lowering at both sites; all
    downstream logic (descriptor mask, OPTIONAL pad, hidden character
    lengths, host-closure args) then runs against the reordered list
    without further changes.
    
    Reproducer: test_programs/audit31_brutal_keyword_args.f90 now
    prints a=20 b=10. Task #482.
    mfwolffe committed
  6. emit fixed-length character initializer bytes in init_decls
    `character(len=N) :: s = 'hello'` silently left the stack buffer
    zero-initialised: init_decls had a catch-all that `continue`d for
    any char_kind != None, on the theory that character initializers
    were "handled elsewhere". There was no elsewhere for the
    fixed-length case. fortsh has ~200 such parameters and every one
    was blank at runtime (audit31 Finding 3).
    
    Before the generic non-plain-scalar skip, check for CharKind::Fixed
    and emit an afs_assign_char_fixed call that copies the literal
    bytes into info.addr with space-padding to the declared length.
    Deferred-length and assumed-length cases still take their existing
    code paths (afs_assign_char_deferred / hidden-length param).
    
    Reproducer: test_programs/audit31_brutal_char_init.f90 now
    prints `hello` and `world` instead of blanks. Task #484.
    mfwolffe committed
  7. accept RESULT and BIND in either order on a FUNCTION header
    The auditor reported "F2003 IMPORT statement in interface body
    unparsed" as Finding 7, with `import :: c_int` triggering
    "parse error: expected expression, got ::". The IMPORT parse
    path was actually fine; the real cause is upstream.
    
    parse_function scanned for RESULT first, then BIND. A header
    like `function foo(x) bind(C, name="foo") result(r)` left
    `result(r)` unconsumed, parse_unit_body entered phase 1 (USE)
    with `result` as the first token, skipped through every phase
    including Phase 1.5's IMPORT recognition, and landed in Phase 3
    parsing `result(r)` as an expression; then the IMPORT line
    showed up and `::` blew up the expression parser.
    
    F2008 R1229 allows both orderings (`R BIND` or `BIND R`).
    Loop over the two clauses until neither matches, so the header
    parser consumes both regardless of order. Same fix unblocks every
    fortsh iso_c_binding file whose interfaces used the swapped form.
    
    Reproducer: test_programs/audit31_brutal_import_stmt.f90 now
    compiles and prints `ok`. Task #488.
    mfwolffe committed
  8. evaluate explicit-shape dummy bounds at runtime (audit31 finding 2)
    arg_dims_from_decls only handles compile-time bounds; when the
    upper bound of an explicit-shape dummy is another dummy argument
    (e.g. `subroutine s(xs, n); integer :: xs(n)`), it fell back to
    (1, 1) and every bounds check on xs reported "index K outside
    [1, 1]". This blocks every BLAS/LAPACK-shaped signature, the
    cross-opt stress harness, and roughly 40% of fortsh call patterns.
    
    Add an install_runtime_dim_bounds pass that, right after the
    normal param spill slots land, walks every by_ref dummy whose
    Explicit-shape bound can't be const-folded AND references an
    already-registered dummy. It lowers the bound expression, widens
    to i64, and stashes the SSA id on LocalInfo.runtime_dim_upper.
    
    Pass visible_param_consts through so module/host PARAMETERS
    stay on the static path (no false positives — realworld_seed_overwrite
    uses `xs(n)` where n is a program-scope PARAMETER; arg_dims_from_decls
    resolves that fine and we must not overwrite).
    
    compute_flat_elem_offset's static-shape path now carries a
    dual static-OR-dynamic cumulative stride. Each dim checks
    runtime_dim_upper first: Some → runtime bounds check + dynamic
    stride; None → const values as before. Once we hit a dynamic
    stride, subsequent dims stay on the dynamic path.
    
    Reproducer: test_programs/audit31_brutal_explicit_shape_bounds.f90
    now passes (CHECK: 150).
    
    Harvest: 12 other audit31_brutal_*.f90 reproducers added with
    XFAIL annotations keyed to tasks #482-#499 so run_programs keeps
    them visible as known-broken, not silently missing. Cross-opt
    harness (lib + main) moved to tests/fixtures/audit31_crossopt/
    since they need multifile linkage, not single-file run_programs.
    
    Known-deferred i128 footprint: unchanged.
    mfwolffe committed
  9. make CI green: clippy, #[ignore] deferred i128, align IPO tests
    Surfacing CI gaps per the last push brought three categories of
    failure to the surface:
    
    1. Clippy: collapsed a nested-if in parser/unit.rs and dropped a
       needless borrow in sema/validate.rs so -D warnings passes.
    2. i128 tests: 11 test functions across i128_formatted_read.rs,
       i128_internal_io.rs, and i128_stack_args.rs exercise code paths
       the backend explicitly rejects today ("backend does not yet
       support integer(16) / i128 codegen"). The feature is tracked in
       .docs/noted_issues.md and task #475. Mark these with
       #[ignore = "...task #475"] so they stay visible in test output
       (as ignored, not failing) until the backend lands.
    3. IPO tests (ipo_const_arg, ipo_dead_arg): both assertions looked
       for renamed specialized callees ("@func_") but the current
       passes specialize and dead-arg-elim in-place — no cloning, no
       rename. Verified the passes produce semantically correct IR and
       runtime output (55/57 for ipo_const_arg, 6 for ipo_dead_arg),
       then aligned the test with the actual correct-by-design behavior.
       The old assertions described a cloning design that was never
       implemented; renaming them would be a follow-up, not a bugfix.
    
    Unused _obj1/_obj2 in tests/incremental.rs: underscore-prefix so
    the warnings don't mask real failures.
    mfwolffe committed
  10. run library unit tests and every integration suite in CI
    test-armfortas was calling `cargo test -p armfortas --bin armfortas`,
    which only tests the binary crate — it has zero tests. The 981
    library unit tests (IR, sema, opt, parser, lexer, codegen, runtime
    stubs) never ran in CI. Change to --lib --release so regressions
    that only surface at -O2 or above don't slip past.
    
    test-end-to-end ran only run_programs, covering the O0..Ofast sweep
    across 290 Fortran programs but skipping every other integration
    target — audit3_reproducers, multifile, cross_opt_abi_matrix,
    module_host_audit, ipo_*, pure_*, licm_lsf_audit_29_11,
    sroa_shape_audit_29_11, mem2reg, vectorize_do_loop, incremental,
    generated_chain_tests, sprint29_audit_realworld, and so on.
    
    Add test-integration that runs `cargo test -p armfortas --tests`
    to cover every tests/*.rs target. Paying the cost of compiling the
    compiler once is cheaper than maintaining 20+ per-target jobs, and
    keeping them together means a regression in any suite blocks the
    merge.
    mfwolffe committed
  11. follow USE renames in find_symbol_any_scope
    A `use m, only: a => add` statement installs a UseAssociation with
    local_name="a" and original_name="add" on the importing scope —
    no symbol named "a" is actually defined. Direct-lookup callers
    (notably resolve_generic_call, used by every call site) missed the
    rename and reported the generic name as unresolved, producing
    `linker: _a undefined`.
    
    Add a second-pass scan: after the direct symbol lookup fails, walk
    every scope's UseAssociations and follow the first matching rename
    back to the original symbol in its source scope. This mirrors
    lookup_in's chain-following behaviour.
    mfwolffe committed
  12. lower derived-type function results; byte-ptr intermediates dispatch
    Three related fixes so chained `a + b + c` on type(vec) operands
    works through a user-defined operator(+) interface:
    
    - derived_type_name_for_return extracts the struct name from a
      function's return_type. Functions returning a type(T) now allocate
      a struct-shaped [i8 x size] buffer and register the result variable
      with derived_type = Some(name), so component-access assignments
      (`vec_add%x = ...`) land on the buffer instead of silently no-oping.
      The return emits a zero-offset GEP of the buffer as a Ptr(i8).
    
    - type_info_to_ir_type returns Ptr(i8) for TypeInfo::Derived instead
      of collapsing to an integer of the struct's byte size. Without
      this, callee_return_ir_type reported an i32/i64 for a derived-type
      result, the caller fed that into iadd as if it were numeric, and
      the verifier rightfully rejected pointer-typed operands.
    
    - arg_matches_declared accepts either Ptr(Array(i8, N)) or Ptr(i8)
      as a match for a declared Derived(T) argument. Function results
      now carry the plain byte-pointer form while local allocas keep
      the sized-array form; both encode the same struct reference and
      the sema layer already guards cross-type derived-type assignments.
    
    Add audit-level tests:
    - audit31_operator_chained.f90 (chained operator + derived result)
    - audit31_host_array_in_function.f90 (host-array access through
      contained FUNCTION's closure-passing ABI)
    - audit31_generic_three_specific.f90 (integer(4)/integer(8) dispatch)
    - audit31_implicit_none_in_module.f90 (implicit none inside module)
    mfwolffe committed
  13. parse comma-separated entity lists in procedure pointer decls
    `procedure(iface), pointer :: f, g` only registered `f`; the parser
    treated the entity name as a singleton and the next iteration of
    the decls loop consumed the comma as a statement boundary, surfacing
    as "expected expression, got ,".
    
    Loop on commas to collect every entity, each with its own optional
    `=> null()` initializer, and emit a single TypeDecl with the full
    entity list — matching the shape that ordinary type declarations
    already use. The interface name and the rest of the procedure-pointer
    ABI work remain deferred.
    mfwolffe committed
  14. fall back to inst.ty when the type cache misses; flag stale entries
    Function::value_type returned None on cache miss, and
    check_type_consistency silently skipped every check when either
    operand had no entry. Optimiser passes that mutate IR without
    calling rebuild_type_cache could ship width-mismatched iadds and
    pointee-mismatched stores undetected.
    
    - value_type now falls back to scanning params and instructions
      for an authoritative `ty` field; the cache stays as a perf
      optimisation but is no longer load-bearing for correctness.
    - verify_function gains check 3a: every defined value must have a
      cache entry. A miss points at the missing rebuild_type_cache
      call rather than the downstream codegen blow-up.
    - check_type_consistency reports missing operand types for
      integer ops so the cause-and-effect chain stays visible if a
      future refactor breaks the fallback.
    - New test type_consistency_survives_stale_cache builds a function
      by hand (no builder, no cache update) and asserts the verifier
      still rejects width-mismatched iadd.
    mfwolffe committed