Skip to content

Latest commit

 

History

History
2777 lines (2501 loc) · 161 KB

TODO.md

File metadata and controls

2777 lines (2501 loc) · 161 KB

RPG IV Free-Format Feature Tracker


✅ Implemented Features

Core Language

  • **FREE, DCL-S (CHAR, VARCHAR, INT, PACKED, ZONED, IND, DATE, TIME, TIMESTAMP, POINTER, UNS, FLOAT, BINDEC)
  • DCL-S keywords: DIM, DIM(*VAR:max), DIM(*AUTO:max), LIKE, INZ, CONST, STATIC, TEMPLATE, BASED, ASCEND, DESCEND
  • DCL-C, DCL-F (stub), DCL-DS/END-DS (QUALIFIED, DIM, DIM(*VAR), LIKEDS, OVERLAY, POS, PREFIX, EXTNAME stub)
  • DCL-SUBF, DCL-PARM
  • DCL-PR/END-PR, DCL-PROC/END-PROC, DCL-PI/END-PI (VALUE, return types, LIKEDS params)
  • DCL-ENUM/END-ENUM (QUALIFIED) (Test 71)
  • EXPORT, IMPORT, EXTPGM, EXTPROC, NOMAIN (Tests 48/49)
  • OPTIONS(*NOPASS) (Test 46), OPTIONS(*OMIT) (Test 64)

Statements & Control Flow

  • EVAL, EVAL-CORR, EVALR, DSPLY, RETURN, CALLP, LEAVESR
  • IF/ELSEIF/ELSE/ENDIF, DOW/ENDDO, DOU/ENDDO
  • FOR/ENDFOR (TO, DOWNTO, BY), FOR-EACH (Test 58)
  • SELECT/WHEN/OTHER/ENDSL, ITER, LEAVE
  • MONITOR/ON-ERROR/ENDMON, BEGSR/ENDSR/EXSR
  • RESET, CLEAR, SORTA, DEALLOC
  • ON-EXIT (Test 42), TEST(D/T/Z) (Test 47)
  • XML-INTO (direct mode, %XML with case options) (Test 87)
  • XML-INTO Phase 2: array DS, PATH option, nested DS/LIKEDS subfields (Tests 88-89)
  • *INZSR (Test 37), *PSSR (Test 66/67)

Expressions & Operators

  • Arithmetic: +, -, *, /, ** (exponentiation, Test 68)
  • Comparison: =, <>, <, >, <=, >=
  • Logical: AND, OR, NOT
  • IN operator (Test 58)

Built-In Functions

  • String: %CHAR, %TRIM, %TRIML, %TRIMR, %LEN, %SUBST, %SCAN, %SCANR (Test 69), %SCANRPL, %XLATE, %REPLACE, %CHECK, %CHECKR, %LOWER, %UPPER, %SPLIT, %CONCATARR, %CONCAT (Test 74), %LEFT, %RIGHT, %STR, %EDITC, %EDITW
  • Numeric: %DEC, %INT, %FLOAT, %UNS, %INTH, %DECH, %DECPOS, %SQRT, %ABS, %DIV, %REM, %EDITFLT (Test 70), %UNSH (Test 70), %PARMNUM (Test 71)
  • Array: %ELEM, %ELEM(arr)=n (Test 72), %LOOKUP, %LOOKUPLT/GE/LE/GT (Test 52), %TLOOKUP/LT/GT/LE/GE (Test 75), %XFOOT, %SUBARR, %MAXARR, %MINARR, %LIST, %RANGE, %SIZE
  • Bitwise: %BITAND, %BITNOT, %BITOR, %BITXOR (Test 68)
  • Date/Time: %DATE, %TIME, %TIMESTAMP, %DIFF, %DAYS, %MONTHS, %YEARS, %HOURS, %MINUTES, %SECONDS, %MSECONDS, %SUBDT
  • Memory/Pointer: %ALLOC, %REALLOC, %ADDR, %PADDR, %PROC, %STR
  • Other: %PARMS, %STATUS, %ERROR, %FOUND, %EOF, %PASSED, %OMITTED, %MAX, %MIN, %GETENV (Test 85), %FKEY (Test 353), %MATCHES/%FIND/%COUNTMATCHES (Test 362), %IF (Test 386), %COMPCORR (Test 411), %REPEAT (Test 418), %PROGNAME (Test 422), %TOHEX/%HEX/%FROMHEX (Test 435)

%STATUS Code Values

Code Description Applicable
0 No error ✅
100 Value out of range for string operation ✅
101 Negative square root ✅
102 Divide by zero ✅
103 Intermediate result overflow ✅
104 Float underflow ✅
112 Invalid date/time/timestamp value ✅
113 Date overflow/underflow ✅
114 Date mapping errors ✅
115 Variable-length field has invalid length ✅
120 Table/array out of sequence N/A (legacy)
121 Array index not valid ✅
122 OCCUR value not valid N/A (legacy)
202 Called program failed ✅
211 Error calling program ✅
221 Called program not found ✅
222 Pointer/parameter error ✅
231 Called program halted ✅
232 Halt indicator on in called program ✅
233 Halt indicator on when RETURN ✅
299 RPG IV runtime error ✅
301-399 File I/O errors N/A (using SQL)
401 Data area not found ✅
402 Data area type/length mismatch N/A (file-based, no schema)
411 Data area not locked N/A (locking is a no-op)
412 Data area lock error N/A (locking is a no-op)
413 Error updating data area ✅
414 User not authorized to data area N/A (IBM i user profiles)
415 Error accessing data area ✅
421 Error calling DSPLY ✅
431 Error calling SND-MSG N/A (IBM i)
450 Character conversion error ✅
500 Failure to allocate storage ✅
802 Failure in sort ✅
803 Error during dump N/A (IBM i)
804 Error in *PSSR ✅
907 Decimal data error ✅
1021-1026 XML parser errors N/A (IBM i)
1211-1299 XML-SAX/XML-INTO errors N/A (IBM i)
9001 Program exception ✅
9999 General ILE RPG error ✅

Date/Time Formats (Test 73)

  • Date: *ISO, *USA, *EUR, *JIS, *MDY, *DMY, *YMD, *JUL, *CYMD, *CMDY, *CDMY, *LONGJUL
  • Time: *HMS, *ISO, *USA, *EUR, *JIS
  • Timestamp: *ISO, *ISO0
  • On MOVE/MOVEL a format may also carry its separator (*MDY/, *MDY-, *MDY., *MDY,, *MDY&) or a trailing 0 for none at all (*MDY0, *ISO0) — Tests 208-211. *JOBRUN is refused (Test 213).

Figurative Constants

  • *BLANKS, *ZEROS, *HIVAL, *LOVAL, *ON, *OFF, *NULL, *INLR, *ALL'x', *OMIT
  • *USER — Current user profile (Test 101)

String Literals

  • Quoted strings: 'text' with '' escape for embedded apostrophe
  • Hex literals: X'0A0D' — converted to byte string at compile time (Test 112)

Indicators

  • *IN01-*IN99 — Fully functional
  • *INLR — Compatibility only (accepted/ignored; no RPG cycle to end)

Compiler Directives

  • /COPY, /INCLUDE, /DEFINE, /UNDEFINE, /IF DEFINED, /IF NOT DEFINED, /ELSE, /ELSEIF, /ENDIF
  • /EOF, /EJECT, /SPACE, /SET, /RESTORE, /TITLE (compatibility)

CTL-OPT (Control Options)

  • NOMAIN (Test 48), MAIN(procname) (Test 56)
  • DATFMT(fmt), TIMFMT(fmt) (Test 57)
  • DFTACTGRP, ACTGRP, OPTION, DEBUG, DECEDIT, CCSID, TEXT, THREAD, COPYRIGHT — accepted/ignored

🔲 Remaining Work

Known gaps

  • None open. The last one, an array of LIKEDS subfields (sub LIKEDS(t) DIM(n)), was fixed 2026-10-27 (tests 447-448): DIM goes before or after LIKEDS or LIKE, %SIZE of a data structure is its length in bytes rather than the C++ object's size, and CLEAR takes a subfield or element (CLEAR ds.sub, CLEAR ds.sub(i)). The 26-vote idea of LIKE through such an array, LIKE(ds.arr.field), needs IBM i to accept it first.

Still TODO

# Feature Section
1 ✅ Operation extenders (E), (H), (N), (M), (R), (P) Opcodes
2 ✅ DUMP opcode (debug) Opcodes
3 COMMIT / ROLBK — Moved to Embedded SQL Phase 2 Opcodes
4 ✅ IN / OUT — Data area operations Opcodes
5 ✅ *USER — Current user profile figurative constant Constants
6 ✅ PSDS — Program Status Data Structure DS Keywords
7 ✅ %ELEM(*ALLOC) / %ELEM(*KEEP) — Varying array control BIFs
8 ✅ SND-MSG — Message operations (7.5+) Modern
9 ✅ DATA-INTO / DATA-GEN (%DATA) Modern
10 ✅ OVERLOAD — Overloaded procedures (7.4+) Modern
11 ✅ Data area operations (IN/OUT/UNLOCK, DTAARA, *LDA/*GDA/*PDA) Data Areas
12 ✅ SQL indicator variables (:var :ind) Embedded SQL
13 ✅ RPG status codes (full %STATUS value set) Error Handling

Record Level Access (via ODBC) ✅

DCL-F, CHAIN, READ, WRITE, UPDATE, DELETE, SETLL, SETGT, READE — implemented via ODBC (Tests 103-108)

Fixed-Format Source Support — Phase 1 ✅

A second, column-based frontend alongside free-format — real legacy RPG is often still fixed-format H/F/D/C specs, sometimes with C-spec logic already modernized into /free.../end-free blocks while H/F/D stay fixed-format (a common incremental-modernization pattern). The AST and codegen are already format-agnostic, so this is purely a new parsing frontend — every existing language feature becomes reachable from fixed-format source once it builds the same AST nodes, no codegen changes needed. Source format is content-sniffed (columns 1-6 of the first substantive line), not extension- gated — **FREE was never a hard mode switch here either.

  • H-spec (control options — MAIN/NOMAIN/DATFMT/TIMFMT, same as CTL-OPT)
  • F-spec (externally-described DISK/WORKSTN files; keyword-tail continuation)
  • D-spec (standalone fields; basic non-OCCURS DS/subfields; name continuation via trailing ...)
  • C-spec via /free.../end-free only — the block body is parsed by the existing free-format lexer/parser unchanged (re-invoked via flex's yy_scan_string), not a new C-spec grammar
  • Implemented via a hand-written column-slicer (src/fixed_columns.h, src/fixed_reader.cpp), not a new flex/bison grammar (Tests 116-119)
  • Deliberately excluded from Phase 1 (see "Legacy / Fixed-Format" below for what's rejected outright vs. deferred): traditional fixed- column C-spec, extended-factor-2 C-spec, I-specs, O-specs — all real engineering, deferred rather than rejected; revisit as a later phase if there's demand

Fixed-Format Source Support — Phase 2 (D-spec depth) ✅

Closes D-spec gaps left basic in Phase 1 — all pure fixed-format parsing gaps (same thesis as Phase 1: the AST/codegen already supported every one of these via the free-format grammar, so fixed_reader.cpp is the only file this phase touches). Motivated by a concrete pain point Phase 1 hit in its own testing: test118's DISK CHAIN needed an integer key instead of the natural VARCHAR key, since fixed-format D-spec had no way to declare one.

  • VARYING{(2|4)} keyword-tail keyword → RPGType::VARCHAR (standalone fields and DS subfields) — verified against SC09-2508's VARYING{(2|4)} entry: keyword-tail only, "not used in a free-form definition" (that's what VARCHAR(n) is for), applies uniformly regardless of standalone- vs-subfield context. Test122 rebuilds Phase 1's test118 DISK CHAIN milestone with a real VARCHAR key, closing that gap.
  • DIM(n) on a DclDS itself (array of DS elements, e.g. items(1).qty)
  • Per-subfield OVERLAY(field), OVERLAY(field:pos), POS(n), LIKEDS(name)
  • Found but out of scope: per-subfield LIKE(...) and per-subfield DIM(...) (an array within one subfield) have no grammar support in free-format either, and DSField has no fields to hold them — extending these would mean growing the free-format language surface itself, not just this frontend. Deferred as a follow-on, not silently dropped.
  • Tests 120-124. Note: DS-subfield CHAR vs VARCHAR currently has no differentiated codegen behavior in this compiler (true for free-format DS subfields too — confirmed by code search, not a fixed-format-specific gap) — test121 is a round-trip check, not a type-distinction check like test120's standalone-field version.

Fixed-Format Source Support — Next Steps (ranked by real-world necessity)

Goal shifted from "cover the common cases" (Phases 1-2) to accepting most real fixed-format RPG IV source, not just H/F/D specs paired with already- modernized /free C-spec logic. Ranked by how much real legacy source each item unblocks, most-needed first:

1. Traditional / extended-factor-2 fixed-column C-spec ✅ (V1). Implemented via a transpile-and-bridge design, not a new grammar: src/fixed_cspec.h/.cpp transpiles each native C-spec line (Factor1/ Opcode/Factor2/Result, or Extended Factor 2's whole-expression style) into its exact free-form-equivalent text, buffers a contiguous run of them exactly like an implicit /free block, and flushes through the same parse_free_block() bridge Phase 1 already built for explicit /free.../end-free — zero new AST nodes, zero new bison grammar, no hand-rolled expression parser. Verified against SC09-2508 (p.559-567: Traditional Syntax, Extended Factor 2 Syntax, continuation rules) — citations live in fixed_columns.h's CSpec namespace.

  • V1 opcode set (Tests 125-134): bare — ELSE/ENDDO/ENDFOR/ ENDIF/ENDMON/ENDSL/ENDSR/ITER/LEAVE/LEAVESR/OTHER/ SELECT/MONITOR; extended-factor-2 — IF/ELSEIF/DOW/DOU/ WHEN/EVAL/EVALR/EVAL-CORR/RETURN/CALLP/FOR/FOR-EACH/ ON-ERROR; traditional Factor1/Factor2/Result — BEGSR/EXSR/ CLEAR/RESET/DSPLY/SORTA. Extended-Factor-2 continuation across physical lines works (Test 133); native C-spec freely mixes with explicit /free blocks in the same file (Test 134).
  • Rejected, not silently dropped (clear, distinct compile errors — Tests 135-138): non-blank control level (RPG-cycle-only, L0/L1-L9/ LR; AN/OR were rejected here too but have since shipped with item #6 — they are conditioning syntax, not cycle syntax), non-blank conditioning indicators (positions 9-11 — since shipped, see item #6), resulting indicators/inline field length (free-form drops these too, so this isn't a new gap), any opcode outside the V1 set — with a distinct message for known-but-deferred opcodes (e.g. SND-MSG) vs. never-planned legacy ones (e.g. ADD).
  • Deferred fast-follow: ON-EXIT/ON-EXCP; XML-INTO/XML-SAX/ DATA-INTO/DATA-GEN/SND-MSG; conditioning indicators (positions 9-11 — since shipped as item #6, exactly as sketched here: the transpiled statement wrapped in IF {N}*INnn; ... ENDIF;); EXEC SQL in fixed columns (architecturally distinct — the SQL capture is a dedicated lexer <SQL> start-condition, not a column-mapped opcode, so it doesn't fit this transpile model cleanly). Traditional legacy opcodes (ADD/ SUB/MOVE/GOTO/etc.) remain item #3 below, untouched by this pass.

1b. RLA opcodes in native C-spec ✅. CHAIN, READ, READP, READE, READPE, WRITE, UPDATE, DELETE, SETLL, SETGT — the same TRADITIONAL-shape mechanism item #1 already built, just more opcode-table entries in src/fixed_cspec.cpp; no new architecture. Verified against this compiler's own parser.y grammar rather than the full IBM manual, since the free-format RLA grammar here is already a simplified subset: no data-structure Result operand on any of these (every *_stmt rule ends at IDENTIFIER SEMICOLON, nothing after the file name), and DELETE takes no key at all — it deletes the last-fetched record, matching tests/test105's own usage, not a delete-by-key Factor 1. SETLL/SETGT have no KW_*_EXT lexer rule, so (unlike the other eight) they don't accept an operation extender. Direct fixed-format analogues of Tests 103-106: Tests 144-147.

2. /COPY//INCLUDE outside /free blocks ✅. Implemented as a recursive, depth-limited line-splicing preprocessing pass (expandCopyDirectives() in src/fixed_reader.cpp) that runs once, before spec-type dispatch begins — so a copied D-spec, F-spec, H-spec, or native C-spec line is indistinguishable from one that was physically present in the outer file. Matches the free-format lexer's own /COPY//INCLUDE convention exactly (src/lexer.l): the text after the directive keyword is a literal filename, opened relative to the process's current working directory — no library/member catalog, no search path. Nesting is capped at the same depth (10) as the free-format lexer's own MAX_INCLUDE_DEPTH, for consistency rather than because either number is load-bearing. Lines between an explicit /FREE.../END-FREE pair are deliberately left untouched by this pass — parse_free_block() re-invokes the real free-format lexer on that text, which already has its own working /COPY//INCLUDE handling, so expanding here too would double-process it (Test 142 proves the two paths coexist correctly). Line numbers after an expansion point are relative to the flattened line stream, not the original file — the same "best effort, not exact" precision the free-format lexer already has today (yylineno isn't saved/restored across its own buffer switch either), not a new regression. Tests 139-143.

3. Traditional legacy opcodes ✅ (V1): GOTO/TAG, ADD/SUB/MULT/ DIV/Z-ADD/Z-SUB. For genuinely old, pre-modern-opcode fixed-format source — distinct from #1, which targets shops already writing modern opcodes in column form; this targets shops that never left MOVE/ADD/ GOTO-style code. The one part of this whole "Fixed-Format Source Support" effort that couldn't just reuse the transpile-and-bridge trick as-is, since GOTO/TAG have no free-form syntax at all (SC09-2508 says so outright for both: "not allowed — use other operation codes, such as LEAVE, LEAVESR, ITER, and RETURN").

  • GOTO/TAG: real new GotoStmt/TagStmt AST nodes + parser.y grammar (src/ast.h, src/codegen.cpp → goto/label in the generated C++ directly), gated by g_allow_goto_tag (src/free_bridge.h) so they're only accepted when the text being parsed was synthesized by the fixed-format C-spec transpiler (fixed_reader.cpp's flushCRun) — genuine free-form text, **FREE top-level or an explicit /free block even inside an otherwise fixed-format file, still correctly rejects them (Test 152), matching the manual exactly rather than silently extending free-form beyond the documented spec. Subroutines (BEGSR/ ENDSR) codegen as C++ lambdas, which happens to enforce real RPG's own GOTO scoping rule (can't cross a subroutine boundary) for free, with no extra validation needed — a TAG/GOTO crossing that boundary anyway just surfaces as a real (if less-polished) C++ compile error, not silently wrong behavior. No "undefined/duplicate label" validation either — C++'s own compiler already rejects that when it compiles the generated code.
  • ADD/SUB/MULT/DIV/Z-ADD/Z-SUB: no free-form keyword either ("not allowed — use the +/+= operator", etc.) but the manual itself prescribes the EVAL/expression equivalent, so these transpile straight to synthesized EVAL text — zero new grammar, same mechanism as items #1/1b. Verified blank-Factor-1 semantics against SC09-2508: Factor 1 present → Result = Factor1 op Factor2; Factor 1 blank → Result = Result op Factor2 (accumulate into the result field) for ADD/SUB/MULT/DIV; Z-ADD/Z-SUB never use Factor 1 at all (Result = Factor2 / Result = -Factor2). Extenders (e.g. (H) half-adjust) pass straight through onto the synthesized EVAL, which already supports them.
  • Deferred, not V1 (documented, not silently dropped): MOVE/MOVEL — real semantic risk, not just unwired plumbing: traditional fixed- length right/left-adjusted move with truncation/padding, plus date/ time-format conversion via D()/T()/Z() Factor-2 suffixes, none of which map onto this compiler's plain-assignment EVAL — shipping an approximate version risks silently-wrong output for edge cases rather than a clear compile error, which this project avoids. COMP — its only real effect (setting HI/LO/EQ resulting indicators) has no free-form target at all, since free-form drops resulting indicators entirely (an existing limitation, not new). CASxx/CABxx — conditional branch-to-tag/subroutine; meaningfully more parsing work for the xx comparison-mnemonic suffix, and with GOTO/TAG/IF all now available, the same logic is expressible as IF cond; GOTO label; ENDIF;. CALL/PARM/PLIST — old-style program calls, a genuinely different mechanism from CALLP needing cross-line state to build a PLIST's parameter list across multiple physical PARM lines (similar complexity to F-spec continuation); CALLP already covers modern program calls. DO — the manual's own guidance is "not allowed — use the FOR operation code," already fully supported. Does not revive the RPG cycle itself (detail/total calc, LR-driven implicit read loop) — that stays rejected regardless of any of this item's future scope.

4. I-specs / O-specs (program-described file I/O) ✅. Unlike items 1/1b/2/3, this couldn't reuse existing infrastructure — every existing file opcode (RLA — CHAIN/READ/WRITE/etc.) is 100% SQL/ODBC against a database table, but program-described I-specs/O-specs describe flat files with byte-position field layout, so this needed a genuinely new raw fixed-length-record file I/O runtime (runtime/rpg_flatfile_runtime.h) built from scratch. Real IBM i program-described files have no in-file delimiters (pure fixed-width bytes on a record-oriented file system); since there's no equivalent on macOS/Linux/Windows, records are newline-delimited fixed-width text instead — an OpenRPG-specific portable convention, not literal IBM i on-disk semantics, documented as such in the runtime header. READ/ WRITE/UPDATE reuse the same ReadStmt/WriteStmt/UpdateStmt AST nodes RLA already uses (a program just writes READ MYFILE; regardless of which kind of file MYFILE is) — visit() for each gained a new branch checked before the existing RLA/"no schema" fallback, mirroring the pattern the WORKSTN branch already used in the same methods.

  • In scope: record identification via character-part (C) tests (position/not/character; zone Z and digit D tests added 2026-10-21, see below), field description (from/to byte position, data type, decimals, field name, field indicators for plus/minus/zero-or-blank), sequential READ with multi-record-type dispatch, UPDATE (rewrite the last-read record in place). One O-spec record format per file (field/constant placement by end-position — width inferred from the gap to the previous field's end position, not by cross-referencing the field's own declared length; edit codes reuse the existing rpg_editc() used by %EDITC), WRITE.
  • Deferred, documented not dropped (found or reconfirmed during implementation, beyond the two flagged when this item was scoped): zone/digit (Z/D) record-identification tests (done 2026-10-21: compared by EBCDIC zone or digit half, CCSID 37; as a Z test character & also matches zone C (A-I) and - zone D (J-R), and no other character does; a record is the FIRST type whose codes it matches, not every one -- rpgc applied every match until then; all checked on PUB400; Test 159); matching fields / multi-file record merging (RPG-cycle-adjacent, permanently rejected elsewhere); CHAIN/SETLL/SETGT/DELETE on program-described files (real keyed access needs F-spec key-field columns not modeled for program-described files); O-spec spacing/skip and printer paging (this compiler has zero PRINTER-output runtime at all, for any file type — a separate, standalone feature, not something O-specs are specifically blocked on); control-level (L1-L9) indicator-on-value-change — parses and is accepted, but doesn't yet set anything at codegen time: wiring it in turned out to need real extensions to this compiler's indicator system (general indicators are a flat bool[100] array with no L1-L9 concept at all, and *INL1-style expression syntax doesn't exist), a meaningfully bigger, separate task once actually reached; multiple O-spec record formats per file — real DDS disambiguates via record-type/EXCEPT names, both already deferred above, so a file needs exactly one O-spec format (a second one is rejected with a clear error, not silently overwritten — found via this session's own test155); date/time/timestamp (D/T/Z) and graphic/indicator-format (G/N) I-spec data formats — rejected at parse time (CHAR text still works fine for a date field if you don't need real date parsing); O-spec relative end positions (+n/-n) — only absolute end positions are supported; sequence checking (I-spec positions 17-20) — not implemented, must be blank.
  • Tests 154-160.

5. Per-subfield LIKE(...)/DIM(...) ✅. Found during Phase 2: neither existed at the subfield level in free-format either, so — unlike items #1-#4, which were pure fixed-format parsing gaps — this needed real ds_field grammar productions in parser.y (DSField gained like_var/ dim members) before fixed_reader.cpp had anything to populate.

  • DIM(n): the subfield becomes an array within the DS (std::array<T,n>), distinct from DIM on the DS itself (an array of DS elements, Phase 2). Accessed via new ds.field(idx) grammar — added at the postfix_expr/eval_target level, not as a flat IDENTIFIER DOT IDENTIFIER LPAREN... production, since the latter would shift/reduce- conflict with plain ds.field (no index) in primary_expr's existing IDENTIFIER reduction; going through the already-reduced postfix_expr nonterminal first sidesteps that (verified no new conflicts vs. this grammar's existing 2 shift/reduce + 1 reduce/reduce baseline).
  • LIKE(other): resolves against a field already declared earlier in the same DS — the reliable, tested case (Test 163's price LIKE(unitPrice) inside the same DS). Referencing an outer standalone field also has fallback code (mirroring top-level DCL-S ... LIKE), but doesn't fire reliably at main/top-line scope: visit(Program) emits every DS struct definition before any standalone field's own DclS (DS structs are needed for procedure params), regardless of the two declarations' textual order — found the hard way via Test 162's first version, which referenced an outer field and silently got the fallback placeholder type. Works fine inside a DCL-PROC body, where declarations emit in textual order.
  • Deferred, not V1: combining LIKE and DIM on the same subfield (e.g. qty LIKE(other) DIM(5)) done 2026-10-27 (test447); DIM(*VAR:n)/DIM(*AUTO:n) on a subfield (fixed-size only, matching the DS-level DIM(n) case this mirrors, not the *VAR/*AUTO forms DCL-S standalone fields support).
  • Tests 161-162 (fixed-format D-spec — declaration only, since fixed_reader.cpp builds DSField directly rather than through the bison grammar); Test 163 (free-format — the one that actually exercises the new ds_field productions, since 161/162's fixed-format source never touches parser.y on the declaration side, only via the /free block's access expressions).

6. Conditioning indicators (positions 9-11) ✅. The highest-value entry on the deferred fast-follow list below when it was written — common in real legacy source and, unlike the RPG cycle's control-level field (positions 7-8), completely independent of cycle semantics. Same transpile-and-bridge design as item #1: position 9 (blank or N) and positions 10-11 (the indicator) become an IF {NOT }*INnn; <stmt> ENDIF; wrapper emitted onto the same buffer line the physical source line already owns, so parse_free_block's line numbers stay aligned exactly as before. No new AST nodes, no grammar change — *IN01-*IN99 were already ordinary expressions (IndicatorExpr, parser.y).

  • Conditionable only where the wrapper is exactly equivalent — that is, self-contained statements: EVAL/EVALR/EVAL-CORR/RETURN/ CALLP; ITER/LEAVE/LEAVESR; every traditional-shape opcode (EXSR/CLEAR/RESET/DSPLY/SORTA, the RLA set, GOTO, and the ADD/SUB/MULT/DIV/Z-ADD/Z-SUB arithmetic set).
  • Rejected on block-structure opcodes (IF/ELSEIF/ELSE/ENDIF, DOW/DOU/ENDDO, FOR/FOR-EACH/ENDFOR, SELECT/WHEN/ OTHER/ENDSL, MONITOR/ON-ERROR/ENDMON, BEGSR/ENDSR) — an IF/ENDIF wrapper around one half of a block leaves the block unbalanced, so there is no honest transpilation; a distinct compile error says so and suggests folding the test into the block's own condition (Test 165). TAG is rejected for a related but different reason: wrapping a label would bury it inside a nested scope its own GOTOs cannot legally jump into.
  • Rejected on indicators this compiler has no representation for — LR, MR, RT, OV, 1P, L1-L9, H1-H9, U1-U8, KA-KY, OA-OG — recognized by name specifically so they get a "not supported here" diagnostic pointing at "Indicator Types" under Not Planned, rather than being reported as a typo (Test 166). 00 and any column-misaligned entry get their own messages (Test 136, repurposed from its previous "conditioning indicators rejected outright" role to the misalignment case its source actually exercises).
  • AN/OR multi-indicator groups (positions 7-8) — RPG IV's C-spec has room for exactly one indicator per line, so AN/OR on a following line is the only way to write a multi-indicator condition; the group's operation code sits on its last line. Terms accumulate in CSpecRunState::condGroups (outer = AND-groups, ORed together) and collapse into one expression when that operation line arrives. RPG relates them as an OR of AND-groups, not left-to-right, so each multi-term AND-group is parenthesized — Test 167 pins this with a (10 AND 30) OR 20 case that only passes under the correct grouping. Note this corrects a wrong claim in the old fixed_columns.h ControlLevel doc string, which lumped AN/OR in with L0/L1-L9/ LR as "RPG-cycle-only" — they have nothing to do with the cycle, and that mischaracterization is likely why they were rejected alongside it. A group whose last line never arrives, an AN/OR with nothing before it, and an AN/OR line missing its indicator each get their own error (Tests 168-169).
  • Extended-factor-2 continuation needed the one piece of new state: the wrapper's ENDIF can't be emitted until whoever appends the statement's terminating ; runs, which for a continued statement is several physical lines later — hence CSpecRunState::pendingSuffix.
  • Still not conditionable from fixed columns: the O-spec's own conditioning indicators (positions 21-29) remain rejected in fixed_reader.cpp — a separate mechanism belonging to item #4, not to this one.
  • Tests 164 (positive: both polarities, all three opcode shapes, conditioned GOTO, conditioned LEAVE/ITER inside DOW loops, and a conditioned statement continued across a physical line), 167 (AN/ OR groups), 165-166 and 168-169 (rejections).

7. MOVE/MOVEL — character moves ✅. Deferred out of item #3 for semantic risk rather than missing plumbing, so this ships the part that can be made exact and refuses the rest outright.

  • What makes it not plain assignment: MOVE aligns factor 2 against the RIGHT end of the result, MOVEL against the LEFT, and the part of the result the move does not reach is left unchanged (the (P) extender blanks it instead). An over-long factor 2 truncates on the side away from the alignment. That needs the result's declared length, so unlike every other opcode here this could not be a pure text transpile.
  • Where the type check lives: fixed_cspec.cpp has no symbol table, so codegen — the first stage that can see declared types — is what refuses a non-character operand. That needed a diagnostic channel after parsing: report_semantic_error (free_bridge.h) reuses report_fixed_format_error's stderr channel and error counter, and main.cpp now re-checks the count once codegen returns.
  • Character-to-character only (superseded by item #13, which added the numeric forms). Numeric, date/time and varying-length results were rejected here, as was a numeric or date/time factor 2, and so is the date/time-conversion form that puts a format in Factor 1 — that one is caught in the transpiler, since it needs no type info (Test 172). Digit-alignment and format-conversion semantics had no representation in this compiler at the time, and approximating them is exactly the silently-wrong-output risk this item was deferred over. Date/time results and the Factor-1 form are still refused.
  • New MoveStmt AST node plus a move_stmt rule gated the way GOTO/TAG already were — MOVE/MOVEL likewise have no free-form syntax at all (Test 173). The gate flag was renamed g_allow_goto_tag → g_allow_fixed_only_stmts now that it guards four opcodes, not two.
  • Cost of making them keywords: MOVE/MOVEL are now lexer keywords, so free-format source can no longer use either as a variable name (DCL-S move CHAR(10) is a syntax error today, where it parsed before). Same tradeoff GOTO/TAG already carry, and both are reserved opcode names in RPG, so real source is unlikely to hit it — noted rather than discovered later.
  • Fixed a latent gap this exposed: a CHAR field's generated std::string is not kept at its declared length (a plain EVAL assigns a shorter string), but real RPG fixed-length fields always are, and MOVE alignment depends on it. Codegen now wraps a CHAR factor 2 in rpg_fixed_len(...), and the runtime normalizes the result field before moving, so MOVEL of a CHAR(5) holding 'AB' correctly moves five characters, not two. Only MOVE/MOVEL are corrected here — the broader "CHAR is not padding-faithful" behavior is untouched elsewhere.
  • Test 170 covers both directions, both truncation sides, (P) on each, and a literal factor 2 (which keeps its own length rather than being padded to a declared one). Test 153 was repurposed from MOVE to CALL, still deferred, so deferred-opcode rejection stays covered.

8. CALL/PARM — traditional program calls ✅. Old-style program calls, pervasive in pre-ILE source and the kind of thing that fails a whole member. V1 supports a CALL with its PARM lines inline immediately after it; named PLISTs were deferred here and shipped in item #14 below.

  • How a prototype-less call gets a signature: CALLP works because DCL-PR ... EXTPGM declared the parameter types; a traditional CALL has no prototype at all. Codegen synthesizes the callee's signature from the PARM operands' own declared types — every parameter a T&, since RPG passes by reference, which is exactly what a non-VALUE DCL-PI parameter generates in the called member. The declaration is emitted at block scope (legal C++, external linkage), so no hoisting pass or output-stream refactor was needed.
  • Mismatches fail at link time, not silently: if caller and callee disagree on types, the two C++ mangled names differ and the link fails. An ugly error, but an honest one — and the same failure mode EXTPGM already has.
  • The program name may be a variable (since #23): program calls are resolved at run time from shared libraries built with rpgc -shared, so CALL pgmvar is supported (Test 177).
  • Cross-line state: CSpecRunState gains a pending-CALL block. The assembled statement is written back into the CALL's own buffer line once the PARM run ends (any other opcode, or the end of the C-spec run), keeping the one-buffer-line-per-physical-line invariant that parse_free_block's line numbers depend on. Blank and comment lines between CALL and its PARMs are fine — they never reach feedCSpecLine. A conditioning indicator on the CALL wraps the whole thing; one on a PARM is refused, since a PARM line is part of the call's parameter list rather than a statement of its own — SC09-2508 p.928 forbids one there outright, which item #14 later confirmed.
  • New CallStmt AST node, gated on g_allow_fixed_only_stmts like GOTO/TAG/MOVE — free-form has CALLP instead (Test 179). As with MOVE, this makes CALL a lexer keyword, so free-format source can no longer use it as a variable name; CALLP is unaffected (flex takes the longest match).
  • Tests 174 (the NOMAIN callee module it links against, same pattern as tests 48/49) and 175 (call, by-reference mutation of both an INT and a CHAR parameter, plus a conditioned CALL in both polarities); 176-179 (rejections).

9. Modern opcodes in native C-spec ✅. XML-INTO, DATA-INTO, DATA-GEN and SND-MSG are now reachable from fixed columns. This turned out to be four table entries and nothing else — the item's framing was more pessimistic than the work.

  • Extended factor 2 is the right shape, and that is the whole trick. Their operands are free-form expressions by nature (%XML(doc:opts), %DATA(...) %PARSER(...), SND-MSG's message-type operand), which is exactly what columns 36-80 plus continuation lines carry through to the free-format parser untouched. Factor 2's traditional 14-column width could never have held a realistic one — reading them as extended factor 2 sidesteps that entirely. No new grammar, AST node, or codegen change.
  • ON-EXIT was mis-filed on this list and is not a port at all. parser.y has no standalone rule for it — it appears only inside the DCL-PROC productions — and fixed-format source cannot declare a procedure, since this reader has no P-spec support. It therefore needs a /free block either way, and now says so in its own error (Test 181) instead of promising a fast-follow that would not help.
  • XML-SAX and ON-EXCP were also mis-filed: they are not implemented anywhere in this compiler (no lexer token, no parser rule), so there was never anything to reach from fixed columns. They stay in deferredOpcodes purely to earn the friendlier "planned" message, and XML-SAX's real home is Modern/Stretch item 46.
  • Test 180 (DATA-INTO with its expression continued across a physical line, plus SND-MSG); all four opcodes share the one code path with no per-opcode logic. Test 137 was repurposed from SND-MSG to XML-SAX, which is still deferred, so the deferred-opcode message stays covered.

10. CASxx / CABxx ✅. Both are comparison-mnemonic opcodes, so neither can be an opcode-table entry — the mnemonic is glued onto the opcode itself (CASGT), and they are matched by prefix ahead of the table lookup. EQ/NE/LT/LE/GT/GE map to =/<>/</<=/>/>=, and the bare CAS/CAB forms are the unconditional ones.

  • CABxx is self-contained: a comparison guarding a branch, so it transpiles to IF f1 <op> f2; GOTO label; ENDIF; — or a plain GOTO label; for bare CAB. It can carry a conditioning indicator.
  • CASxx is a group, and that is the real work here: the lines chain like SELECT/WHEN (the first true comparison runs its subroutine, the rest are skipped), so a group transpiles to one IF/ELSEIF/ELSE chain that ENDCS closes with ENDIF. State lives in CSpecRunState alongside the AN/OR and pending-CALL accumulators. A conditioning indicator on a single arm is refused — it would leave the chain unbalanced, the same reasoning as the block-structure opcodes.
  • Degenerate groups are caught, not miscompiled: a group led by an unconditional CAS needs no IF and so gets no ENDIF; arms after an unconditional one are unreachable and rejected; an orphan ENDCS (Test 183), a group left unclosed at the end of the run (Test 184), and an unrecognized mnemonic (Test 185) each get their own message.
  • Test 182 runs the same group four times, hitting each arm including the ELSE, then exercises both CABxx forms.

11. COMP ✅ — and the reason it was deferred turned out to be wrong. The old entry said COMP's only effect, setting the HI/LO/EQ resulting indicators, "has no free-form target at all". That is true of resulting indicators as a column feature — free-form drops them — but not of COMP, because *INnn is an assignable target in this compiler. Each non-blank slot simply becomes one indicator assignment: *IN10 = (a > b); *IN20 = (a < b); *IN30 = (a = b);. Exact, not approximate.

  • COMP is therefore the one opcode allowed to fill positions 71-76; every other opcode still rejects them. The three slots got their own ColSpecs (71-72 high, 73-74 low, 75-76 equal) and reuse the conditioning indicators' validation, so *INLR and friends are refused the same way there as anywhere else.
  • A COMP with all three slots blank does nothing at all and is rejected rather than silently emitted (Test 187).
  • Test 186 reads the results back through conditioning indicators, exercising both halves of the indicator support against each other.

12. EXEC SQL in fixed columns ✅. C/EXEC SQL … C+ … C/END-EXEC. The old entry called this "architecturally distinct — the SQL capture is a dedicated lexer <SQL> start-condition, not a column-mapped opcode, so it doesn't fit the transpile-and-bridge model". The start-condition part is right; the conclusion was not. Gathering the C+ lines and emitting one free-form EXEC SQL …; hands the text to that very start condition, which captures it exactly as it does in free-format source — so the bridge model fits perfectly and no SQL parsing was added at all.

  • Handled ahead of the ordinary column layout, since position 7 carries the / or + marker that the control-level field would otherwise reject. The gathered statement is written into the C/EXEC SQL line's own buffer entry, keeping the one-line-per-physical-line invariant.
  • An orphan C/END-EXEC (Test 189) and a block never terminated (Test 190, plus the same check at run flush) each get their own error.
  • Test 188 goes end to end against SQLite: connect, create, two inserts, a SELECT … INTO host variable, and a COUNT(*), with the CREATE TABLE continued across two C+ lines.

13. MOVE/MOVEL — numeric operands ✅. The rest of item #7, and the one the deferred list called the most commonly hit in real legacy source. The reason it was deferred — "digit-alignment semantics have no representation in this compiler" — turned out to be a runtime gap, not a semantic one: the semantics are exact and citable, they just needed a digit string to act on.

  • The rule that decides everything: SC09-2508 p.633, "if move operations are specified between numeric fields, the decimal positions specified for the factor 2 field are ignored. For example, if 1.00 is moved into a three-position numeric field with one decimal position, the result is 10.0." So a numeric MOVE is not an assignment and not a scaling conversion — it is the same positional move the character form already does, run over the field's digits instead of its characters. Neither operand's decimal point participates. Test 191 opens with that exact example.
  • Reducing a numeric to digits is the whole implementation. A PACKED/ZONED field is a C++ double here, which carries neither a digit count nor a decimal place, so codegen passes both declared numbers in (var_digits_/var_decimals_, with the same var_lengths_ fallback %SIZE already uses) and rpg_num_digits(v, digits, dec) builds the fixed-width digit string. The move then runs positionally on that string exactly as rpg_move_fixed does on characters, and the result is read back through the result field's decimal places. Same three cases as the character form (factor 2 longer / shorter / equal), same (P) behaviour except that the pad character is '0' for a numeric result and stays blank for a character one.
  • llround, not a cast, when scaling by 10^dec — binary floating point lands an exact 1.00 at 99.999999, and a truncating cast would turn the manual's own worked example into 099. Found by running it.
  • The sign rules are asymmetric and are implemented as written, not approximated: MOVE always moves factor 2's rightmost position, which is where the sign lives, so factor 2's sign always wins. MOVEL (p.905) "retains the sign of the result field except when factor 2 is as long as or longer than the result field", so a shorter factor 2 leaves the result negative if it already was. Test 191 pins all four combinations.
  • Character→numeric reads EBCDIC digit portions and zones (changed 2026-10-13, against PUB400). "The digit portion of each character" is the low half of its EBCDIC code, so 'A' (x'C1') moves as 1 with no data error, and 'A1 ' moves as 110 (Test 196). The result is negative when the rightmost character's zone is D ('J'-'R', '}'). The bytes are read through CCSID 37 (rpg_ebcdic37). "Blanks are transferred as zeros", so a CHAR(5) holding '123' moves the digits 12300 (Test 192). A digit portion above 9 (. < ( + * and others) is a decimal data error, status 907, that ends the program unless MONITOR handles it, with the result unchanged (checked on PUB400 2026-10-21 for ten such characters; Test 440).
  • Numeric→character carries the sign in the last digit's zone (changed 2026-10-13, against PUB400), as a zoned number does on IBM i: -12345 moves as 1234N, a negative last digit reading } for 0 and J-R for 1-9 (rpg_digits_text; Test 193).
  • Still refused, each with its own message: float operands (the manual disallows them outright — Test 194), date/time results and factor 2s, and a decimal literal as factor 2 (Test 195) — it reaches codegen as a double having lost the trailing zeros that decide its digit count, and '1.00' and '1.0' move differently. An integer literal is accepted, since the digits written are the digits it has.

14. Named PLIST and PARM factor 1/2 ✅. The two thirds of the deferred PLIST entry that did not need any new signature machinery.

  • A named PLIST forces a collect-then-substitute pass, and that is the whole reason it was deferred: "a parameter list is ended when an operation other than PARM is encountered" (SC09-2508 p.930), but nothing requires the PLIST to appear before the CALL that names it — real source routinely groups every PLIST at the bottom. A linear transpiler cannot resolve that in line order, so a CALL with a name in its Result field now records its buffer index and is filled in by flushCSpecRun, once every PLIST in the run has been seen. Test 197 puts the definition after all five calls that use it, which is the case that fails without the pass.
  • plists is the one piece of CSpecRunState that survives a flush. A PLIST is a program-wide declaration, but a C-spec run ends at any interleaved spec type, so a run boundary between the definition and a later use would otherwise lose it. The reverse order — a CALL in an earlier run than its PLIST — is still refused, since the substitution happens at that earlier run's flush; it gets a real diagnostic naming the list (Test 200), never a silently argument-less call.
  • PARM factor 1/2 turned out to be two plain assignments. The deferral said the direction semantics were unverified, and p.929 settles them: on the call "the contents of the factor 2 field ... are copied into the result field", and on return "the contents of the result field ... are copied into the factor 1 field" — each "in the same way as data is moved using the EVAL operation code". So factor 2 is a seed-in and factor 1 a harvest-out, and buildCallText brackets the CALL with one assignment apiece. Both land on the CALL's own buffer line, because the PARM lines sit after it in the source and their own entries could never hold statements that must run before it. Test 198 runs all three shapes (both operands, factor 2 only, factor 1 only).
  • Result-field restrictions are enforced, not assumed. p.929 bars *IN/*INxx, literals, named constants and table names from a PARM Result field, and bars a literal from factor 1 (which is written to on return). Indicators and literals are refused by name (Test 202); a named constant is indistinguishable from an ordinary name at this stage and reaches codegen, which has the symbol table.
  • One diagnostic per mistake. A rejected PLIST line sets plistSuppress, so its orphaned PARM lines stay quiet instead of each reporting itself — without it, *ENTRY PLIST reported two errors for one cause. Duplicate names (Test 201) and an empty PLIST (Test 176, repurposed from the old "named PLISTs are rejected" test) each get their own message.
  • *ENTRY PLIST shipped separately as item #15 — it turned out to be a different problem entirely, and needed a design decision rather than more transpiler work.

15. *ENTRY PLIST ✅. The last of the deferred PLIST entry, and the one that was mis-scoped: the old note said it "needs the main-program signature machinery", but there is no such machinery. DCL-PI appears only inside DCL-PROC in parser.y, and a compiled program is an int main() taking no arguments — free-format has no way to give the main program parameters either. So this was never a port; it was a decision about what a program with parameters compiles to at all.

  • A member with an *ENTRY PLIST compiles to a callable function, not a main(). void <NAME>(T1&, T2&, ...), holding the mainline, with every parameter a reference because RPG passes by address — which is exactly the signature a caller's traditional CALL already synthesizes from its own PARM operands (item #8), so the two link with no new machinery on the calling side. The alternative, filling parameters from argv, was rejected: a process cannot write back to its caller, so the output half of *ENTRY semantics would be silently lost, and output parameters are the common case in real entry lists.
  • The name comes from the source file, upper-cased with directory and extension stripped: ORD100.rpgle is reachable as CALL 'ORD100'. Fixed-format source has no P-spec to name a procedure with, so the file name is the only thing both sides can agree on. A file name that is not a usable symbol is refused with an explicit "rename the source file" message rather than emitting an uncompilable one. This is why the test callee is tests/ADDTWO.rpgle and not a testNNN name.
  • The parameters are declared twice and stored once. They are ordinary D-spec fields and the function's C++ parameters, so codegen registers their types as usual but suppresses the local declaration (entry_params_) — leaving the caller's storage as the single location the body reads and writes, which is what p.929's "each parameter field has only one storage location" requires.
  • Factor 1 and factor 2 both work. p.929 step 3 puts the result→factor 1 copy "after it receives control and after any normal program initialization", so it is emitted once, after *INZSR, before the first executable statement. Step 4's factor 2→result copy happens on return, at every exit point. It was refused until 2026-10-21; it is now an object at the top of the entry function (RpgOnReturn) whose destructor makes the copies, so every way out runs it -- a RETURN in the calculations or a subroutine, or the end -- and an error ending the program does not (Test 205, callee COPYBACK).
  • NOMAIN with *ENTRY is an error (Test 199, repurposed): NOMAIN discards the mainline, which is the very thing *ENTRY turns into the function, so together they compiled to an empty file.
  • Fixed a pre-existing bug this exposed: a bare RETURN emitted return 0; regardless of the enclosing function's return type, so it did not compile inside any void function — a DCL-PROC with no return type already had this, *ENTRY just made it unavoidable (a fixed-format mainline almost always ends in C RETURN). Codegen now tracks void_return_ and emits a bare return;.
  • Tests 203 (the ADDTWO callee) and 204, which calls it twice — once with inline PARMs and once through a named PLIST defined after the call — so items #14 and #15 meet end to end. 205-207 are the rejections.

16. MOVE/MOVEL date/time conversion ✅. The last of the deferred fast-follow list, and what items #7 and #13 both left out: a date, time or timestamp on either side of the move, plus the Factor-1 format form (C *MDY MOVE chr datefld) that #7 rejected outright.

  • It turned out not to be a different kind of move at all. The reason this was deferred — "genuine format conversions rather than positional moves" — held only for the conversion. SC09-2508 p.406 settles the rest: "If character or numeric data is longer than required, only the leftmost data (rightmost for the MOVE operation) is used." That is the same left/right positional rule #7 and #13 already implement. So the conversion produces a text exactly as wide as the format defines, and rpg_move_fixed/rpg_move_num_fixed then place it unchanged — one new step in front of machinery that already existed, not a second move implementation. Test 211 pins both directions of that.
  • Thirteen combinations, and the manual lists them by name (p.405): each of Date/Time/Timestamp to its own type, Date and Time to a timestamp, Timestamp to a date and to a time, each of the three to character or numeric, and character or numeric to each of the three. Date-to-Time and Time-to-Date are absent, and are refused with a message pointing at the timestamp, which is the only thing that carries both (Test 171, repurposed from #7's date rejection).
  • Factor 1 is the format of the character or numeric operand, never of the date field. "Factor 1 must be blank if both the source and the target of the move are Date, Time or Timestamp fields. If factor 1 is blank, the format of the Date, Time, or Timestamp field is used." Both halves are implemented: a factor 1 with two date/time operands is an error (as is a factor 1 with neither — Test 172, likewise repurposed), and a blank one falls back to the field's own DATFMT/TIMFMT, then the H-spec's, then *ISO.
  • A format carries its own separator. *MDY/ names one explicitly, *MDY0 says the character field has none at all, and a bare *MDY takes the format's default. Codegen validates the separator against the set the format allows (Tables 13, 15 and 16) — and needs no separate default-separator table, since the default is the first entry in each of those sets. Numeric operands never carry separators, per "If the result field is numeric, separator characters will be removed."
  • The year-range rules are enforced, not just documented. A 2-digit year format reaches 1940-2039 and a 3-digit one 1900-2899 (p.193), and a value outside the result field's range is error 114 with the result left unchanged — which is what Figure 287's own *HIVAL move produces. Like 112 (not a valid date), it ends the program unless MONITOR handles it: MOVE has no (E), and the error indicator in positions 73-74 is not supported here (changed 2026-10-13, against PUB400; Tests 208, 212, 439). This is checked even on a plain date-to-date move, where the internal value is format-independent here, so that example reproduces exactly.
  • Fixed a pre-existing bug this uncovered: *CYMD/*CMDY/*CDMY were being rendered and parsed as c/yy/mm/dd — ten characters with the century digit separated — where Table 15 gives cyy/mm/dd, nine, with the century digit joined to the year. The century digit was also treated as a 19xx/20xx flag rather than its documented 1900 + c*100 range. Both were wrong in %CHAR(date:*CYMD) and %DATE(str:*CYMD) too, so the fix is in the shared legacy helpers, not just the new path.
  • Fixed-format D-specs now read per-field DATFMT/TIMFMT. Only the H-spec forms were parsed before; the free-format DCL-S has always had the per-field keywords, and a blank factor 1 needs them.
  • Still refused, each with its own message: *JOBRUN (Test 213), which takes its format and separator from runtime job attributes this compiler has no equivalent of; time format *USA against a numeric field, which the manual disallows outright and whose AM/PM suffix is not a digit anyway (Test 214); and a varying-length result, which was already out of scope for the character form.
  • (P) on a date/time result is accepted and does nothing — there is no remainder to blank in a date field. It still pads a character result the conversion did not fill (Figure 289's *ISO0 timestamp).
  • Tests 208-210 reproduce Figures 287, 288 and 289 value for value, so the manual is the oracle rather than this compiler's own output. 211 is the alignment/truncation pinning above, 212 the runtime failures (status 112, result unchanged), 213-214 the rejections.

Fixed C-spec — Deferred Fast-Follow ✅ (cleared)

Items explicitly deferred (not silently dropped) out of items #1 and #3 above when each shipped — kept here as their own trackable list instead of staying buried in a completed item's writeup. Both entries are now closed.

  1. MOVE/MOVEL date/time conversion. — done: item #16 above.
  2. Named PLIST, *ENTRY PLIST, and PARM factor 1/2. — done: items #14 and #15 above. Factor 2 on an *ENTRY PARM, the one piece left behind, was done 2026-10-21 with a scope guard (Test 205). Writing its test found two bugs in called programs with subroutines: a RETURN in a subroutine of an *ENTRY program generated return 0 in a void function and did not compile, and the subroutine-return flag was shared with the caller, so a callee's subroutine RETURN ended the caller too. Both fixed.

Fixed-Format File I/O — Not Planned

Native record format / INFSR / legacy PLIST-based file I/O — item #4 above does now implement program-described (byte-position) record I/O for fixed-format source; what stays not-planned here is INFSR (file exception/error subroutines) and legacy PLIST-based parameter-list file operations, neither of which item #4 touches.

Embedded SQL (via ODBC)

Phase 1 — Foundation ✅

# Feature Details
18 ✅ EXEC SQL parsing Lexer <SQL> start condition, captures raw SQL until ; as EXEC_SQL_TEXT token
19 ✅ Host variables Extract :varName references, replace with ? parameter markers
20 ✅ ExecSqlStmt AST node Single node with SqlStmtKind enum (avoids 15+ separate node classes)
21 ✅ SQL utility functions src/sql_utils.h/.cpp — extractHostVariables(), replaceHostVarsWithMarkers(), classifySqlStmt()
22 ✅ ODBC runtime wrapper runtime/rpg_sql_runtime.h — RpgSqlEnv class (connection, cursor, prepared stmt management)

Phase 2 — Core SQL Statements ✅

# Feature RPG Syntax ODBC Mapping
23 ✅ SELECT INTO EXEC SQL SELECT col INTO :var FROM tbl WHERE ...; SQLPrepare + SQLBindCol + SQLExecute + SQLFetch
24 ✅ INSERT EXEC SQL INSERT INTO tbl VALUES(:v1, :v2); SQLPrepare + SQLBindParameter + SQLExecute
25 ✅ UPDATE EXEC SQL UPDATE tbl SET col = :v WHERE ...; Same as INSERT
26 ✅ DELETE EXEC SQL DELETE FROM tbl WHERE ...; Same as INSERT
27 ✅ COMMIT EXEC SQL COMMIT; SQLEndTran(SQL_COMMIT)
28 ✅ ROLLBACK EXEC SQL ROLLBACK; SQLEndTran(SQL_ROLLBACK)
29 ✅ SQLCODE / SQLSTATE SQLCOD / SQLSTT variables SQLGetDiagRec → __sql_env.sqlcode / .sqlstate

Phase 3 — Cursor Operations ✅

# Feature RPG Syntax ODBC Mapping
30 ✅ DECLARE CURSOR EXEC SQL DECLARE C1 CURSOR FOR SELECT ...; SQLPrepare (store handle in cursor map)
31 ✅ OPEN EXEC SQL OPEN C1; SQLExecute on cursor's stmt handle
32 ✅ FETCH EXEC SQL FETCH C1 INTO :v1, :v2; SQLBindCol + SQLFetch
33 ✅ CLOSE EXEC SQL CLOSE C1; SQLFreeStmt(SQL_CLOSE)

Phase 4 — Connection Management ✅

# Feature RPG Syntax ODBC Mapping
34 ✅ CONNECT EXEC SQL CONNECT TO :dsn USER :u USING :p; SQLConnect
34a ✅ CONNECT (conn string) EXEC SQL CONNECT USING :connStr; SQLDriverConnect
34b ✅ CONNECT RESET EXEC SQL CONNECT RESET; SQLDisconnect
35 ✅ DISCONNECT EXEC SQL DISCONNECT; SQLDisconnect
36 — SET CONNECTION Not supported (single connection per program) —

Phase 5 — Dynamic SQL ✅

# Feature RPG Syntax ODBC Mapping
37 ✅ PREPARE EXEC SQL PREPARE S1 FROM :sqlStr; SQLPrepare (store handle)
38 ✅ EXECUTE EXEC SQL EXECUTE S1 USING :v1, :v2; SQLBindParameter + SQLExecute
39 ✅ EXECUTE IMMEDIATE EXEC SQL EXECUTE IMMEDIATE :sqlStr; SQLExecDirect

Phase 6 — Advanced Features ✅

# Feature RPG Syntax ODBC Mapping
40 ✅ Indicator variables :var :ind SQLLEN indicator in bind calls
41 ✅ GET DIAGNOSTICS EXEC SQL GET DIAGNOSTICS :rc = ROW_COUNT; SQLGetDiagRec / SQLGetDiagField
42 ✅ CALL procedures EXEC SQL CALL proc(:p1, :p2); SQLPrepare("{CALL proc(?,?)}") + bind
43 ✅ SAVEPOINT EXEC SQL SAVEPOINT sp1; SQL passthrough (driver-dependent)
44 ✅ Multiple-row FETCH FETCH ... FOR :n ROWS Loop with SQLFetch into array elements
45 ✅ Multiple-row INSERT INSERT ... FOR :n ROWS Loop with SQLExecute per array element

Architecture Notes

  • Lexer: EXEC SQL triggers exclusive start condition <SQL>, captures until ;
  • Parser: Single exec_sql_stmt rule; classification via utility function (not a full SQL parser)
  • AST: Single ExecSqlStmt node with SqlStmtKind enum
  • Runtime: Separate rpg_sql_runtime.h (programs without SQL don't need ODBC)
  • Linking: -lodbc added only when SQL is used (codegen sets uses_sql_ flag)
  • SELECT INTO: INTO :var1, :var2 clause stripped from SQL sent to ODBC (uses SQLBindCol instead)

Modern/Stretch

# Feature
46 XML-SAX (%HANDLER)

🔬 IBM i Conformance (real-compiler oracle)

What this is: the test corpus is compiled on a real IBM i (PUB400, V7R5M0) by IBM's own ILE RPG compiler, and its accept/reject verdict is compared against rpgc's. This is the only check that can find the one class of bug the local suite structurally cannot: source rpgc accepts that real RPG rejects. tests/expected_output/ is regenerated from rpgc's own output by run_tests.sh --update, so the local suite can only ever verify what rpgc already does. An independent compiler is the only oracle that breaks that circularity.

Script Purpose
scripts/ibmi-probe.sh One-shot recon: what works on the box and how. Not needed routinely; kept because every parameter in it was found by failure.
scripts/ibmi-conformance.sh Uploads the corpus, compiles each source, reports accept/reject grouped by diagnostic. Writes ibmi-conformance-report.txt + -transcript.txt.
scripts/fix-fixed-format-columns.py Corrects fixed-format sources to what IBM actually requires (see findings 1 and 2 below). Re-runnable.

Non-obvious parameters (all found by failure, none guessable)

  • Source must be tagged CCSID 819. UTF-8/1208 fails RNS9339 "unable to open file", which reads like an authority problem and is not.
  • TGTCCSID(*JOB) is mandatory. The job CCSID is 273 (German; PUB400 is German-hosted) despite the profile being LANGID ENU / CNTRYID US. The default target is *SRC (500), under which [ ] { } in literals come back as Ä Ü ä ü in real program output. TGTCCSID(37) is worse — it breaks !.
  • Each system invocation is its own job. QTEMP does not survive between calls and CHGJOB does not stick. Compile into a real library; CRTLIB is not authorized, but the profile's current library (LONGDM1, not LONGDM) works — read it from DSPUSRPRF, do not derive it from the profile name.
  • Compile listings arrive on stdout, severity totals included. No spool retrieval, no EVFEVENT, no CPYSPLF needed.
  • DFTACTGRP(*NO) is required for prototypes/subprocedures/ON-EXIT; NOMAIN sources need CRTRPGMOD, not CRTBNDRPG.
  • DSPLY output is unreachable from stdout — it posts to the job's external message queue, which dies with the job (verified against all 14 system flags). printf via BNDDIR(QC2LE) reaches real stdout; EBCDIC newline is x'25'. This is what a future differential execution run would need.
  • PASE grep is AIX-derived: no -o.

Results — 2026-08-29

221 compiled, 72 accepted. That headline number is not a quality metric: it conflates four unrelated things, and a large share of the rejections are the correct outcome.

Bucket Files Meaning
A. Needs IBM i objects 17 *FILE/*DTAARA referenced via EXTDESC/DTAARA don't exist on the box. Not a language issue.
B. rpgc leniency — real findings 35 rpgc accepts source IBM rejects. The actual deliverable.
C. Not a valid standalone program 22 Declaration-only tests, spec-ordering. RNF7023 "cannot determine how the program can end".
D. Negative tests 43 IBM rejecting may be correct — see caveat below.
E. Individual review 19 —
SQL dialect 13 CONNECT USING :connStr is ODBC-flavoured; DB2 for i wants CONNECT TO :rdb USER :u USING :pw (SQL0199).

Findings (the point of the exercise)

  1. Column adjustment is not enforced. fixed_columns.h::extractCol() trims both ends, so "0 " and " 0" are indistinguishable. IBM requires numeric entries right-adjusted and reports RNF0263 at severity 20. 89 D-specs across 67 corpus files were malformed and nothing ever flagged them. Corpus fixed; rpgc still does not diagnose this.
  2. Blank Definition-Type accepted as "standalone". IBM reads blank positions 24-25 as subfield of the enclosing group; with no group open that is RNF3703, severity 20. rpgc infers "standalone". 94 declarations across 72 files. Corpus fixed; rpgc still does not diagnose this.
  3. BIFs permitted in Factor 1 of traditional C-specs. C %CHAR(i) DSPLY is accepted by rpgc; IBM allows built-ins only in extended Factor 2 (RNF0372 + RNF5261, 16 files). Not corrected — the fix restructures the test (EVAL to a temp, then DSPLY) rather than reformatting it, so it needs a decision about what those tests are meant to demonstrate.
  4. DSPLY caps at 52 characters, enforced at compile time on the declared length, not the runtime value (RNF7016). 8 files still declare wider fields. No expected-output line exceeds 40 chars, so %SUBST(f:1:52) is viable.
  5. UTF-8 em-dashes cannot survive the required CCSID 819 tag (U+2014 has no Latin-1 code point). 37 files carry them in comments; the conformance script folds them to ASCII at staging time.

Decision needed on 1-3: should rpgc enforce these, or only warn? Being more permissive than IBM costs nothing for the goal of eating real-world source — real source is well-formed. But silently accepting is exactly what let 183 malformed declarations accumulate unnoticed across 72 test files. Recommendation: warn, don't reject.

Bucket A — object setup (done 2026-08-30)

scripts/ibmi-setup-objects.sh creates everything the corpus references, all of it derived rather than hardcoded: tables from the .extdesc sidecars, program-described record lengths from the F-specs, data areas from DTAARA() keywords. Re-runnable. NOSUCHDA96 is deliberately NOT created — test96_da_status401 asserts the data-area-not-found status.

Created: 12 tables, 7 program-described PFs, 1 data area, 1 printer file. Effect: RNF2120 "external description not found" went from 13 files to 2, and the record-format clash is gone. The accepted count barely moved (72 -> 73) because those files each carry several independent problems; clearing the dependency layer exposes the next one rather than flipping the file green.

Three more parameters that are not guessable, all found by failure:

  • DECIMAL(9,2) is a parse error on this box. The job is German-locale, so 9,2 lexes as the number 9.2. DECIMAL(9 , 2) — spaces around the comma — parses. Do not "tidy" those spaces away.
  • RCDFMT is required on CREATE TABLE. SQL names the record format after the table, and RPG rejects a format whose name matches the file (RNF2121 + RNF2109).
  • RUNSQLSTM needs ERRLVL(30). SQL0204 (dropping a table that isn't there) is severity 30 and the default stops the whole script at the first one.

Findings 6-8 (from the object-setup work)

  1. Data area names longer than 10 characters. RPGCTEST97DA and RPGCTEST98DA are 12 chars; IBM i object names cap at 10 (CPD0074), so these objects cannot exist. rpgc accepts arbitrary-length names. Fix is to rename in the corpus — not creatable on the IBM i side.
  2. EXTDESC literals are case-sensitive on IBM i. RPG uses the quoted literal verbatim as the object name, and IBM i object names are uppercase, so a lowercase literal can never resolve (RNF2120). rpgc treats it case-insensitively and generates lowercase table= lines in the .extdesc sidecars it writes. Corpus fixed (11 files uppercased, rpgc output verified byte-identical); rpgc's sidecar generator still emits lowercase.
  3. The .extdesc model cannot express CHAR vs VARCHAR. Both are recorded as std::string(N), so rpgc cannot detect a VARCHAR key chained against a CHAR key field. IBM enforces it (RNF7080 "Factor 1 field is not the same type as first key field"). The tests' own EXEC SQL CREATE TABLE declares VARCHAR, which is what the setup script now creates.

SQL decimal comma — SOLVED (2026-08-30)

CRTSQLRPGI ... OPTION(*PERIOD). The verification job is German-locale, so the SQL precompiler read , as a decimal point and VALUES(1,'Alpha') failed with SQL0104 "Token ,1 was not valid". *PERIOD pins the decimal point regardless of job locale. Note DECMPT() is not a parameter of this command — the decimal point is an OPTION value, which is not obvious from the name.

Effect: Token ,NNN errors went from pervasive to one. It did not move the accepted count, because it was masking the real blocker rather than being it — but it converted "16 files failing for unclear reasons" into "16 files failing for one well-understood reason", which is what made the next finding visible.

SQL connection model — a platform divergence

What is left in the SQL group is SQL0199 "Keyword USING not expected" on:

EXEC SQL CONNECT USING :connStr;
EXEC SQL DISCONNECT;

OpenRPG connects with an ODBC connection string (Driver={SQLite3};Database=/tmp/x.sqlite). DB2 for i connects to a relational database named in the RDB directory: CONNECT TO :rdb USER :u USING :pw. A connection string has no expression as an RDB name, so this is not a syntax difference that could be reconciled — the two connect to different kinds of thing.

Verified by removing just the connection statements from test79_sql_core: every SQL error at severity >= 20 cleared. (An RNF0637 remained from the RPG side, in SQL-generated code; likely an artefact of the crude line removal, not chased.)

Same category as record formats and free-format DDS: a divergence that follows from the target platform, not a defect. 14 CONNECT and 13 DISCONNECT statements across the tests.

Both compilers do support an ambient connection, which is worth stating because it bounds the problem: IBM i's job is implicitly connected to its local database, and rpgc reads RPGC_DSN (src/conf.cpp). Source carrying no CONNECT compiles on both — 8 of the 24 SQL tests are already in that shape, and it is how most shop RPG is written.

DECISION (2026-08-30): the 16 CONNECT USING tests stay as they are. Connecting by connection string is a real rpgc feature and needs test coverage; rewriting those tests to use RPGC_DSN would make them compile on IBM i but would stop testing the thing they exist to test. The 16 files are counted as a platform divergence permanently, not as work.

SUPERSEDED (2026-09-25): both compilers now accept the same files. rpgc predefines the condition *OPENRPG, and the 18 tests wrap their CONNECT USING/DISCONNECT in /IF DEFINED(*OPENRPG). rpgc still compiles and runs the connection-string path, so it is still tested. IBM i skips those lines and uses the job's own connection. Probed on PUB400 before changing anything:

  • CRTSQLRPGI ignores /IF by default and parses the SQL in both branches. RPGPPOPT(*LVL2) is required: it runs the RPG preprocessor first. ibmi-conformance.sh now passes it.
  • IBM accepts an unknown * condition as simply undefined, so *OPENRPG is safe there.
  • A bare DISCONNECT fails too (SQL0104) and must be guarded as well. CONNECT RESET is valid Db2 for i and stays unguarded.
  • A static statement against a missing table is only SQL1103 severity 10, and the program is still created. Tables need not exist to compile.

The "reject" entries for these tests are gone from tests/ibmi-expected.txt.

With SQL0199 gone, the first IBM i run (36088083268) accepted 12 of the 18 and found real differences in the other 6:

  • test78: procedures need CTL-OPT DFTACTGRP(*NO) (RNF1520). Test fixed.
  • test82: Db2 for i requires SAVEPOINT sp1 ON ROLLBACK RETAIN CURSORS. rpgc now accepts it and sends the portable SAVEPOINT sp1 (portableSavepoint() in sql_utils).
  • test83: IBM i moves multiple rows only through a host structure array (a DS array), and spells the blocked INSERT INSERT INTO t (cols) :n ROWS VALUES(:ds). rpgc now supports both. One array per column with a trailing FOR :n ROWS still works and is still tested, under /IF DEFINED(*OPENRPG).
  • test105, test219, test238: RNF5063, the database record-format divergence below. Not changed, by decision (2026-09-25): no /IF workaround either. Those tests and test146 are expected IBM rejections in tests/ibmi-expected.txt.

Found on the way, fixed separately: CLEAR generated nothing for a data structure, a DS array or a FLOAT, and invalid C++ (ARR = 0;) for a standalone array. It now clears every element and subfield, including through a LIKEDS parameter, and clears CHAR to blanks rather than to an empty string. RESET had the same gaps (and RESET of an array with INZ referred to a shadow copy that was never declared); it now restores whole arrays, CTDATA arrays, varying arrays and data structures to their starting values.

Still open (small): whether rpgc should also accept CONNECT TO :rdb USER :u USING :pw so shop code carrying the IBM spelling compiles. Narrow — it only affects source connecting to a remote database, since local access needs no CONNECT at all. Same coverage-versus-conformance question as database record formats. Not started.

Harness fix: route by content, not extension

11 .rpgle files carry embedded EXEC SQL and were being sent to CRTBNDRPG, which parsed EXEC SQL ... as an EVAL (RNF5347). That looked like a language finding and was purely a routing bug. Routing is now by file content.

Bucket B — fixed-format tightening (in progress, 2026-08-30)

rpgc now diagnoses the divergences the IBM i oracle found. All are warnings, not errors: being more permissive than IBM costs nothing for real-world source, which is well formed, but silently accepting malformed source is what let 183 bad declarations accumulate across 72 test files unnoticed.

New machinery:

  • report_fixed_format_warning() (parser.y) — prints, does not touch g_error_count, so it cannot fail a build.
  • extractColRaw() (fixed_columns.h) — untrimmed slice. extractCol() trims both ends, which is precisely why adjustment was invisible to this compiler.
  • warnIfNotRightAdjusted() (fixed_reader.cpp) — shared across F/D/I/O specs.

Checks added, each naming the IBM diagnostic it mirrors:

Spec Check IBM
F Record-Length, Key-Field-Length right-adjusted RNF0263
F File-Type (pos 17) is I/O/U/C RNF2003
F File-Format (pos 22) is F or E RNF2006
D From-Position, To/Length, Decimal-Positions right-adjusted RNF0263
D blank Definition-Type with no open group RNF3703
I From/To-Position, Decimals, record-ID positions right-adjusted RNF0263
O End-Position right-adjusted RNF0263

Also a correctness fix, not just a diagnostic: a non-blank definition type now ends an open DS group. Previously only a non-D specification did, so an S standalone declared after a data structure was silently absorbed into it as a subfield — which is not what IBM does.

Validated both directions: replaying the pre-fix corpus (git show 21b987e^) raises the warnings on the files IBM rejected, and the current corpus raises exactly 24 warnings across exactly the 7 files IBM still flags (test154-test160) — an independent match with the oracle.

Note tests/run_tests.sh discards stderr for passing tests, so warnings do not surface there. Run rpgc directly to see them.

Finding 9 — a conflict, not a leniency (fixed 2026-08-30). Two positions where rpgc required blank and IBM requires a value, so no source could satisfy both:

Position was (rpgc) IBM now
I-spec Sequence 17-18 error if non-blank required, RNF4008 if blank alphabetic accepted (means "no sequence checking", which is what rpgc does); numeric still rejected
O-spec record Type 17 error if non-blank required, RNF6005 if blank D (detail) accepted; H/T/E still rejected (need cycle timing)

This is the other direction of the conformance matrix — rpgc rejecting valid RPG rather than over-accepting — and the first coverage fix the oracle produced.

Corpus (13 files) brought to IBM-acceptable form, with the derivation rules encoded in fix-fixed-format-columns.py rather than hand-edited: File-Type from what the source does (I-specs only -> I, O-specs only -> O, both -> U plus A), File-Designation F for I/U/C and blank for O, File-Format F where a record length is present, Sequence AA, record Type D. Result: test159 is now fully accepted by IBM, test160 is down to its own deliberate error (RNF4059), and bucket B fell 53 -> 48 as those files moved into bucket C with only structural problems left.

All transforms now share one fixed_only() guard. Column 6 is meaningless in a /FREE block, so record.id = 1; looks exactly like an O-spec — that hazard corrupted a test three separate times during development before the guard was centralised.

Enforcement (2026-08-30). All conformance checks are now errors, not warnings. IBM reports every one at severity 20 (program not created), so source that trips them would not compile on a real IBM i either — rejecting it cannot break valid real-world code. report_fixed_format_warning() was removed once it had no callers.

Findings 10-12, all fixed on both sides:

  1. Free-format code inside a fixed-format source must start at position 8. Positions 1-5 are the sequence area and 6-7 the form type and comment flag. The corpus indented /FREE bodies by two spaces, so NAME = 'x'; put NAM in the sequence area and E in the form-type column — RNF0257 at severity 30, across 24 files and 162 lines. rpgc accepted any column. Both fixed: blocks shifted (relative indentation preserved, longest resulting line 62 columns), and rpgc now rejects code before position 8.
  2. **FREE is a file-level declaration, so a /COPY member is read as fixed-format unless it declares **FREE itself — exactly as on IBM i. rpgc's lexer returned KW_FREE as a token the grammar only accepts at program start, so a copy member could not declare its own format. Fixed: the directive is consumed inside an include.
  3. Specification order. O-specs must follow the calculations, and main-procedure calculations must precede any DCL-PROC — statements after END-PROC are "between procedures" (RNF0256, severity 30). rpgc does not care about either. Corpus fixed; rpgc still does not enforce order.

Also recorded: test61_no_free asserts that rpgc accepts free-format without the **FREE directive. IBM requires it, so IBM rejecting that test is the CORRECT outcome — it belongs in the extension quadrant, not the work queue.

Progress: accepted 73 -> 88 of 221. Bucket C (structural) fell 26 -> 8; RNF0256 is gone entirely, RNF0257 8 -> 4 files, RNF7023 16 -> 4.

Finding 3 resolved (2026-08-30), and the rule is broader than first recorded. Tested directly on IBM i 7.5 rather than inferred:

form IBM
built-in in Factor 1 (DSPLY, IFGT) rejected
built-in in traditional Factor 2 (MOVE) rejected
built-in in extended Factor 2 (EVAL) accepted
field or literal in Factor 1 accepted

So built-ins are illegal in both traditional-syntax factors, not just Factor 1 — they are expressions, and traditional factors take a field, literal or constant. rpgc now rejects them there (containsBuiltIn() in fixed_cspec.cpp, applied only in the TRADITIONAL branch, skipping % inside character literals so '100% done' is not misread).

Corpus: 44 DSPLY sites across 19 files rewritten as EVAL TMPDSP = %CHAR(n) followed by DSPLY TMPDSP, with one CHAR(52) work field per file — every built-in used was %CHAR or %TRIM, both character, and DSPLY caps at 52 anyway. No site carried a conditioning indicator, and the transform bails out if one ever does rather than silently dropping the condition. Copybook fragments get the rewrite but not the declaration, since their parent declares it.

Progress: accepted 88 -> 100 of 221; bucket B 46 -> 29.

RLA C-spec group resolved (2026-08-30), 3 of 5.

KEYED is a free-form DCL-F keyword. In a fixed-format F-spec, keyed access is the Record-Address-Type entry: K in position 34. IBM rejects the keyword with RNF2367 at severity 20. rpgc accepted it in the keyword tail as a deliberate extension (the comment in finalizeFSpec said so), and never read position 34 at all — FSpec::RecAddrType was defined and unused.

Both fixed: rpgc now reads position 34 and rejects the keyword in fixed format; five F-specs moved. The RNF7055 "Factor 1 ... is not valid" errors on their CHAIN statements turned out to be knock-on from the malformed F-spec and cleared with it — not the CHAR/VARCHAR mismatch first suspected. Verified on the machine before changing anything.

Accepted 100 -> 103.

Still open in bucket B:

  • test118_fixed_rla_chain is not an RLA problem: it fails the SQL precompile on the decimal-comma locale, so it belongs in bucket E.

Record formats — a platform divergence, not a defect

WRITE, UPDATE and DELETE against an externally-described file take the record FORMAT name in Factor 2 on IBM i, not the file name (RNF5063, RNF5198 on test146_fixed_cspec_write_upd_del).

This is not something to "fix" in rpgc. Record formats exist on IBM i because DB2 for i tables are native *FILE objects underneath — CREATE TABLE produces a file with a record format (named for the table, or set by RCDFMT). On SQLite, PostgreSQL, DB2 LUW — anywhere rpgc actually runs — no such layer exists. A table is a table. rpgc's RLA resolves against table= in the .extdesc sidecar, and there is no format name to resolve because the concept has no referent off IBM i.

(The IRecordFormat/ORecordFormat types in the AST are unrelated: they model I-spec/O-spec record identification for program-described flat files.)

DECISION (2026-08-30): rpgc does not support record formats for database files. It does support them for display files.

The line is drawn where the concept has a referent:

File type Record formats Why
DISK (database) no The target is an SQL table. SQLite, PostgreSQL and DB2 LUW have no record-format layer, so there is nothing for a format name to denote. RLA resolves against table= in the .extdesc sidecar.
WORKSTN (display) yes A display file genuinely is a set of named record formats, and EXFMT must name one. Already modelled: ExFmt::format in ast.h, and OpenDSPF's descriptor carries records[].

Not a gap to close later — a deliberate boundary. Supporting DB record formats would mean inventing an IBM i concept the runtime cannot honour, and coupling source to whatever RCDFMT a file happened to be created with.

Consequence, stated plainly: shop RPG that names a database record format in WRITE/UPDATE/DELETE/READ will not compile unmodified; the file name has to be substituted. That is a real coverage cost on the "compile real-world RPG" goal, accepted knowingly.

test146 counts as a platform divergence alongside free-format DDS, not as a defect in the work queue.

  • test118_fixed_rla_chain is not an RLA problem: it fails the SQL precompile on the decimal-comma locale, so it belongs in bucket E.
  • Bucket F grew to 18 as files shed their bulk diagnostics and reached single, individual causes — that is progress, not regression.

TAG label position — fixed (2026-08-30)

TAG is the mirror image of GOTO: GOTO names its target in Factor 2, TAG declares its label in Factor 1 with Factor 2 blank. rpgc treated the two identically and wanted the label in Factor 2 for both, which IBM rejects with RNF5009 ("Factor 1 operand is required") plus RNF5025 ("Factor 2 entry is not blank").

Verified on the machine before changing anything: C LOOPTOP TAG compiles, C TAG LOOPTOP does not. Fixed in fixed_cspec.cpp and in 6 lines across 4 tests. Accepted 103 -> 106.

Still open, one file: test182_fixed_cspec_cas_cab uses a bare CAB for an unconditional branch:

C                   CAB                     done

IBM requires both factors even with no comparison operator (RNF5009 + RNF5023), though a bare CAS on the same file is accepted. Either supply the factors or use GOTO for the unconditional case. Narrow, one file, not chased.

Caveat on bucket D — needs a human pass

Bucket D mixes two things the diagnostics cannot separate:

  • Genuinely invalid RPG (test11a_missing_semi, test11c_unmatched_if, test184_err_cas_unclosed) — IBM rejecting is the pass condition.
  • The unsupported/ quadrant (test135_err_ctllevel, test138_err_legacy, test148_goto_tag) — valid RPG that rpgc rejects on purpose. IBM ought to accept these. It didn't: test135 returned RNF5002, which suggests the test's own control-level usage is malformed rather than IBM agreeing with rpgc. Some of bucket D is hiding further source bugs.

Splitting these requires reading each test's intent; it is not inferable from the output.

Next steps (ranked)

  1. Object setup for bucket A — generate DDL from the .extdesc sidecars and create the *FILE/*DTAARA objects once in LONGDM1. Cheapest real win: 17 files stop failing for reasons unrelated to the language.
  2. Bucket B → diagnostic work on the fixed-format reader (findings 1-3).
  3. Cache the oracle. Commit sha256(source) -> {verdict, diagnostics} so CI compares against cached results offline and PUB400 is consulted only for changed files. This is what turns the exercise into a build step.
  4. Human pass over bucket D (above).
  5. Differential execution — built 2026-09-26; see "Differential execution" below. It routes DSPLY to a message queue rather than rewriting it to printf.

The build step (2026-08-30)

Three pieces turn the manual expedition into something CI runs.

Piece Role
ibmi-conformance-baseline.json The record: SHA-256 of every source plus the verdict IBM gave it — and, for a rejection, the root-cause reason, IBM's messages (code, line, text) and the date it was last compiled. Committed — this, not the transcript, is the durable artefact.
scripts/conformance-baseline.py check (offline gate), changed (what to send), update (merge a run, report regressions, record reasons), rpgc (record rpgc's own verdicts — compile-only, rpgc -c, as IBM compiles each source on its own; linking made the ten caller/callee halves of multi-program tests count as rejections).
scripts/conformance_lib.py Shared: reading a transcript, extracting IBM's messages (wrapped text joined), tracing a rejection to its root cause.
scripts/conformance-wiki.py Renders the wiki page IBM-i-Conformance-Results: every program, whether it should compile on IBM i, whether it did, whether rpgc compiles it, and IBM's reason when not. The workflow builds rpgc and records its verdicts first.
tests/ibmi-expected.txt Exceptions to the should-compile rule (an error-mode test should be rejected, everything else should compile), each with its reason.
scripts/conformance-diff.py Writes ibmi-conformance-differences.md: every file where rpgc and IBM disagree.
.github/workflows/ibmi-conformance.yml offline job on every push/PR; verify job on demand only (the weekly schedule was removed 2026-09-23). changed, full or smoke; after changed/full it commits the refreshed baseline back to main (not on a regression; the report and differences files only after full) and publishes the results page to the wiki.

The offline gate needs no network and no secrets, so it runs on fork pull requests too. It fails when a source has changed since it was last verified — i.e. when someone edits a test without re-verifying it. That is the check that makes the whole thing durable: without it the baseline silently rots.

The online job sends only sources whose hash moved. A no-op run exits immediately without contacting the machine at all.

What fails a build is a REGRESSION — a source IBM accepted before and rejects now. The absolute accepted count is deliberately not a gate: it conflates missing IBM i objects, deliberate negative tests, and real findings, so gating on it would fail builds for reasons that say nothing about rpgc.

Setup required before the verify job can run: two repository secrets, PUB400_SSH_KEY (the private key matching what ssh-copy-id installed) and PUB400_USER. Until they exist the job fails fast with a clear message rather than timing out. The workflow pins the host key with ssh-keyscan instead of trusting on first use.

Note the transcript is per-run, not cumulative: a --changed-only run leaves a transcript covering only those files. The baseline is what accumulates.

Compatibility page (removed 2026-09-29)

The wiki's IBM-i-Compatibility page, generated by hand with scripts/conformance-summary.py, was removed along with the script: nothing kept it current, and it had fallen far behind the data. IBM-i-Conformance-Results (compiling) and IBM-i-Execution-Results (running) are the published results.

The conformance matrix

The headline "accepted N of 221" understates agreement, because a rejection can be the correct outcome. Comparing both compilers' verdicts as of 2026-08-30:

rpgc accepts rpgc rejects
IBM accepts 88 12
IBM rejects 80 41

129 of 221 agree (58%). The 41 mutual rejections are negative tests working as intended. The 80 over-acceptances are the real work queue. The 12 nominal coverage gaps are partly a measurement artefact — several are link failures from compiling callee modules (ADDTWO, test48_nomain, test174, test197) standalone, not compile errors; a compile-only measurement would shrink that column.

Conformance run 2026-09-23, and four IBM rules enforced

Full corpus re-run on PUB400 (265 sources). Agreement with IBM rose from 58% (2026-08-30) to 165 of 265 (62%). scripts/conformance-diff.py writes ibmi-conformance-differences.md: every disagreement, grouped by root cause, with IBM's message, source line and text.

  • The run's five "regressions" were environmental. PUB400 had deleted the test files and data areas in LONGDM1; ibmi-setup-objects.sh recreates them. It now also honours the .extdesc column kind (CHAR vs VARCHAR, DOUBLE for a float column).

  • Enforced in rpgc, matching IBM's message IDs:

    • %DIV/%REM by a literal 0 (RNF0552)
    • a CHAR/VARCHAR INZ longer than the field (RNF3431)
    • PREFIX on a program-described DS (RNF3529)
    • ds.field on a DS that isn't QUALIFIED (RNF7030)

    Tests 239, 250 and 253, written this week, had used three of these; they are corrected. Test 63, whose premise was PREFIX on a program-described DS, is now an error test. Tests 255-257 are new. IBM was re-run on all seven: every one agrees.

  • The remaining leniency queue, from the differences report, largest first:

    • declarations after calculations (17)
    • DSPLY of a field declared longer than 52 (10)
    • an unquoted DTAARA(name) (4)
    • operations after ENDSR (3)
    • type mismatches (3)
    • LIKE of an unqualified sibling subfield (2)

DSPLY conforms to IBM i (2026-09-24)

Two DSPLY rules, both found by test 87 (whose XML-INTO IBM accepted all along — every error was in the DSPLY lines printing the results):

  • An expression must be in parentheses (RNF0637). DSPLY's operands are blank-separated (message, message queue, response), so DSPLY 'Total: ' + x; does not parse on IBM i; DSPLY ('Total: ' + x); does. Expressions carry a parenthesized flag for this.
  • The message is at most 52 characters, judged from declarations (RNF7016). CodeGen::displayLength computes the maximum as IBM does: literals, declared CHAR/VARCHAR lengths, %CHAR of a number (digits + sign + point; 11 for an INT, 21 for integer arithmetic), of a date (10), time (8), timestamp (26), float (14/23), indicator (1), %TRIM/%SUBST, and concatenation. It matched IBM's reported length on every flagged line in the corpus.

36 tests broke one rule or both. The free-form ones were fixed by script (operand parenthesized; an over-long message assigned to a VARCHAR(52) work field first); the fixed-format report fields were shrunk from 60/70 to 52, changing only trailing blanks. Test 217's detail line was 59 characters of real content, so it now prints over two lines with every value kept. On PUB400, 15 of the edited tests now compile — among them XML-INTO tests 87-89 — and IBM rejects negative tests 262-263 as rpgc does. Known wrinkle: in a fixed-format C-spec run, rpgc can report a statement's line a few lines late (test 217: 113 for 104).

DCL-PI must be named (2026-09-23)

A procedure interface's name is *N or the procedure's own name; IBM i rejects anything else with RNF3767. Written with no name (DCL-PI INT(10);) IBM reads the return type as the name, so the same message covers both. rpgc accepted both; the pi_name rule now checks as soon as DCL-PI is read, so the error carries the DCL-PI's line, as IBM's does. Tests 08 and 78 and four docs/GUIDE.md examples were unnamed; test 08 now compiles on IBM i. Tests 264-265 are the rejected forms, both matched on PUB400.

Subroutines come last (2026-09-23)

Once a procedure's first BEGSR appears, only more subroutines may follow in it; IBM i ignores each calculation after an ENDSR with RNF5005 (severity 30). rpgc ran such statements as mainline. checkSubroutinesLast now reports each one, in the main procedure and in each subprocedure, skipping declarations (RNF0724/0725's to report), subprocedure definitions and fixed-format I/O layouts. Tests 13, 66 and 67 had mainline after their subroutines; with the subroutines moved to the end, all three compile on IBM i, output unchanged. Test 266 is the rejected form; IBM and rpgc flag the same three lines.

Compound assignment (2026-09-24)

+=, -=, *=, /= and **= are parsed as target = target op (value), with cloneExpr copying the target, for a plain assignment, EVAL and EVAL(x), so codegen needed nothing new. rpgc had none of them, and IBM i accepts them all; test 267 covers each operator, arrays, qualified subfields, strings and EVAL(H), and compiles on PUB400. Found alongside: fixed-format C EVAL *INLR = *ON was a syntax error in rpgc; it works now (rechecked 2026-10-27).

*INLR is a real indicator (2026-09-24)

*INLR = *ON; was the only form rpgc knew, and it compiled to RETURN — so the statements after it never ran, where IBM i runs the rest of the calculations and ends the program where the mainline ends. *INLR = *OFF, IF *INLR, NOT *INLR and fixed-format EVAL *INLR = *ON were syntax errors. *INLR is now an indicator (IndicatorExpr::LR, emitted as rpg_inlr), and fixed-format LR/NLR conditioning and COMP resulting indicators accept it. As on IBM i, a program that tests LR without setting it anywhere is rejected (RNF7030); test 166 now fails for that reason, the same line and code as IBM. Tests 268-269 compile on PUB400.

With LR off at the end of the mainline, an IBM i cycle program with no primary file runs its calculations again; rpgc does too since 2026-10-27 (test453, checked on PUB400).

Found alongside, both done 2026-10-27 after probing PUB400:

  • IBM rejects any indicator referred to but never set (RNF7030), not only LR: 01-99 and H1-H9 as rpgc has them. An assignment, a resulting indicator, an I-spec or a display file sets one; passing it as a parameter does not. Any use of the *IN array as such (*IN(i), %ADDR(*IN), CLEAR *IN) turns IBM's check off; rpgc has no *IN array. Tests 449-450.
  • Factor 1, factor 2 and the result field must each be left-adjusted (RNF0262, severity 20); an indented extended factor 2 is fine. Tests 451-452.
  • Also found: rpgc had no SETON/SETOFF and no *IN array; both done 2026-10-27 (tests 454-455): *IN(i) as a value or target (status 121 outside 1-99), *IN = value and CLEAR *IN. Then BASED data structures (free and fixed, with LEN), indicators held as '0'/'1' bytes so one can be based on %ADDR(*IN), %LOOKUP over *IN, and a pointer's INZ, which was dropped (tests 456-458); then LIKEDS, LIKE and DIM subfields inside one, OVERLAY(ds:pos) on one, and bare DIM subfields of any data structure without QUALIFIED, which were a compile error (tests 459-462). Not done: the indicators U1-U8, KA-KY, L1-L9, MR, RT, 1P, OA-OG, OV, which IBM i takes and rpgc will not (2026-10-27).

BOOLEAN removed, DCL-ENUM in IBM's syntax (2026-09-24)

rpgc accepted a BOOLEAN data type as an alias for IND, added in the belief that IBM shipped one with DCL-ENUM in 7.5 TR3 / 7.4 TR9. It did not: IBM i rejects DCL-S x BOOLEAN; (RNF3308). Removed; use IND.

DCL-ENUM constants were written RED = 1;, or RED; for an automatic 0, 1, 2...; IBM i accepts neither. Its form is DCL-C's: RED 1; or RED CONST(1);, with the value required (RNF3905 without one). Checked on PUB400: character values ('O'), negative values and x IN enum are all valid, and rpgc now handles each — constants are typed from their value as DCL-C constants are, and IN expands the enum into its constants. An operation-code name (open) cannot be a constant on IBM i; rpgc does not check that. Test 71 now compiles on IBM i; test 270 is the rejected form.

OVERLOAD follows IBM i (2026-09-24)

rpgc turned OVERLOAD into C++ overloading, so C++ picked the closest candidate: abs(-7) went to an INT(10) candidate over a FLOAT(8) one. IBM i does not rank candidates. A call must fit exactly one, and all three of IBM's OVERLOAD rules are now enforced, each matched on PUB400:

  • the overloaded prototype is one statement, no END-PR (RNF3551);
  • every candidate returns the overload's type (RNF3244);
  • a call that fits no candidate is RNF3245, one that fits several RNF3246. A VALUE/CONST parameter takes any argument of its type family (number, character, date, time, timestamp, indicator, pointer); a by-reference parameter takes only a variable of exactly its type (checked on PUB400: a literal fits neither of two by-reference candidates).

resolveOverload sends each call straight to the one candidate it fits. When an argument's type cannot be worked out in codegen, it falls back to the old C++ wrappers rather than report a false error. Test 110 was rewritten: INT-vs-FLOAT dispatch through VALUE is ambiguous on IBM i. It now overloads by type family and by number of parameters, and compiles on PUB400. Tests 271-274 are the four rejected forms.

Found alongside: typed literals (D'2024-01-15', T'...', Z'...') and %DATE(string : *ISO) with a format were syntax errors in rpgc; both work now (rechecked 2026-10-27).

Differential execution (2026-09-26)

scripts/ibmi-execution.sh compiles every standalone run test on PUB400, runs it, and compares what it displays with tests/expected_output/. This is the oracle for behaviour: the compile check cannot see a program both compilers accept but run differently, and expected_output/ is written by rpgc itself. On demand: gh workflow run ibmi-conformance.yml -f mode=execute. Results: ibmi-execution-differences.md, from ibmi-execution-baseline.json (IBM's output per source, keyed by SHA-256), and the wiki page IBM-i-Execution-Results (scripts/execution-wiki.py, published by the execute run). Why each program differs is kept by hand in tests/ibmi-execution-notes.txt (rpgc / platform / test / open); its rpgc entries are the work queue, and a fix deletes its line.

How, all verified on the machine first:

  • DSPLY goes to a message queue, not printf. DSPLY's second operand names a message queue. scripts/exec_transform.py adds 'RPGCOUT' to every DSPLY (free, fixed, conditioned, multi-line); the harness reads the queue back in order (QSYS2.MESSAGE_QUEUE_INFO). Nothing else in the source changes, so this checks IBM's own DSPLY formatting. The printf route would have replaced DSPLY with %CHAR and hidden exactly that.
  • INQMSGRPY is *RQD. An unhandled runtime error sends an inquiry to the system operator and the job waits forever (found by hanging one, which had to be ENDJOBed). Programs run through a CL driver that sets INQMSGRPY(*DFT) and reports RPGCRUN-ERROR <msgid>.
  • A watchdog ends a program still running after 60s. The driver records its job in a data area, and the watchdog ENDJOBs it.
  • Program-described files keep their records between runs, so each DISK file a test names is cleared (CLRPFM) first.
  • Other LONGDM jobs (TLS, CORS) run on the box concurrently. A first version deleted every spool file the profile created during the run, which took four of their job logs. It now only reports its own driver jobs' leftovers.

First run (full, 175 programs): 124 same, 31 differ, 19 runtime errors, 1 timeout. 8 run tests not run (linked pairs, IBM rejects). A first-pass triage, not yet investigated test by test:

Group Tests
%EDITC/%EDITW keep their fixed width (leading blanks); rpgc trims 215, 220, 221, 222, 223, 224, 225, 32
%CHAR of FLOAT is +1.500000000000000E+000 60, 70
%CHAR keeps an expression's decimals (112.50, not 112.500000); zero is .00 161, 162, 163, 23, 39
DS without INZ starts as blanks: decimal data error (MCH1202), blank INT reads as 1077952576 249, 91, 230, 236, 23
One-off behaviours 26 (%SIZE of pointer 16), 33 (%REPLACE), 50, 69 (%SCANR), 67 (*PSSR), 90 (PSDS user/job), 93/96 (data areas), 102 (SND-MSG escape), 155/216 (I-spec record ID), 193/196/208/212 (MOVE), 228 (loops forever), 267, 283, 87/88
Probably platform divergence DATA-INTO/DATA-GEN name rpgc's built-in parsers (RNX0355: 99, 100, 112, 113, 114, 180, 87?); 54 (program name); 115 (DUMP goes to spool)

Fixed 2026-09-29: DUMP only when IBM i would dump (test115, test313, test314). DUMP writes a dump only with CTL-OPT DEBUG(*YES)/DEBUG(*DUMP) (or H DEBUG), or as DUMP(A); otherwise it does nothing -- rpgc had always dumped. Where the dump goes stays a platform difference by decision: IBM i writes a spooled file, rpgc prints it (tests 313 and 314). Found alongside: the fixed-format C-spec DUMP operation is not supported.

*Fixed 2026-09-29: SND-MSG ESCAPE (test312; test102 partly). By default an *ESCAPE goes to the caller: the sending procedure ends and its own MONITOR and *PSSR do not see it; the caller gets status 202 (CPF9898 in the PSDS), and from the main procedure it ends the program. %TARGET(*SELF) sends it to the procedure itself, status 9999. rpgc had thrown it at the sender's own MONITOR and printed it. Verified on PUB400. *INFO/*DIAG/*COMP still print to stderr, by decision: rpgc has no job log (test102 is a platform difference).

Fixed 2026-09-29: data area locks and errors (test93, test96). OUT needs the data area locked (IN *LOCK) -- status 412 without; OUT releases the lock, OUT *LOCK keeps it, UNLOCK releases it (unlocking one not locked is not an error). *LDA/*GDA/*PDA need no lock. An error (401 not found, 412, 413, 415) ends the program unless (E) or a MONITOR handles it; rpgc had only set %STATUS. IN(E), OUT(E) and UNLOCK(E) are now accepted. All verified on PUB400.

Fixed 2026-09-29: XML-INTO matches as IBM i does (test87, test88). The options decide the match and a mismatch is status 353: case=lower (the default) / upper / any; allowmissing=no (default) needs an element or attribute per subfield, =yes keeps the old value of one without; allowextra=no (default) needs a subfield per element; without a path the outermost element is named as the variable, and a path names the element to read from the root (for an array, the repeated one: path=items/item). rpgc had matched loosely, set missing subfields to zero and ignored extras. For arrays IBM ignores surplus elements (no error): a fixed array takes its dimension, a DIM(*VAR) array its current count (unchanged), a DIM(*AUTO) array up to its maximum and its count becomes the number read. All verified on PUB400.

Fixed 2026-09-29: the PSDS, and %SUBST out of range (test90, test91, test311). The PSDS used an invented layout (program name at 81, user at 91, job number at 101...), where IBM i has the library at 81 and exception data at 91. It now uses IBM's positions -- status 11, previous status 16, parameters 37, exception type/number 40/43, exception data 91, job 244-269, run date/time 276/282, program 334, module 344, current user 358 -- and is refreshed at every ON-ERROR and *PSSR, so a handler sees the error's status and ID; it had been filled once, at start. %SUBST was plain substr(): a start past the end threw a C++ exception (status 0 in a MONITOR, an abort outside one) and a length past the end quietly shortened the result. Both are status 100 now. All verified on PUB400.

Found alongside: %SUBST(x : 1 : 3) = ... (%SUBST as an assignment target) is not supported; and IBM rejects a constant %SUBST start beyond the declared length at compile time (RNF0364), which rpgc does not check.

*Fixed 2026-09-29: PSSR endings and errors leaving a procedure (test67, test239, test310). A *PSSR run because of an error that reaches ENDSR (no RETURN) ends the program in error (RNX9001) -- rpgc had ended normally, so nothing could tell the program failed. In a procedure, the procedure ends in error instead. An error that leaves a procedure reaches its caller as status 202 ("called program or procedure failed"), whatever it was inside; fitting a VALUE argument is part of the call and keeps its own status (103). RETURN in any subroutine now returns from the procedure or program (above). All verified on PUB400.

Found alongside: the one-line DCL-PI *N INT(10) END-PI; (no parameters) is valid on IBM i and a syntax error in rpgc.

Fixed 2026-09-28: %SCAN / %SCANR (test69). Both search a portion: from the start (default 1) for the length (default: to the end), and a match must fit inside it. A %SCANR start begins the portion; rpgc had searched only up to it. The length argument was ignored for both. A start outside the source (even one past the end) or a length past the end is status 100; with no start, an empty source is 0. All verified on PUB400.

Fixed 2026-09-28: %DECPOS (test50). It is the operand's declared decimal positions, a constant worked out at compile time -- 2 for a PACKED(7:2) whatever it holds -- not the scale of the value (it counted 3.5 as 1). A decimal literal keeps the scale it is written with (1.50 is 2, not 1), + and

  • take the larger scale, * the sum, %DEC(x:p:s) s. All verified on PUB400.

Fixed 2026-09-28: %REPLACE (test33). Without a length it replaces as many characters as the replacement has, cut off at the end of the source; it had inserted. An omitted start is 1; a start outside the source, or a length given that runs past its end, is status 100 (all verified on PUB400).

Fixed 2026-09-26: ON-ERROR codes, keyword names, array bounds (150 of 180).

  • ON-ERROR takes status codes, named constants, *PROGRAM (100-999), *FILE (1000-9999) and *ALL, and a MONITOR may have several clauses. The first match handles the error; an error no clause lists goes on up (test305). Only a bare ON-ERROR; parsed before.
  • Names: IBM reserves almost no words (probed on PUB400: every keyword and operation code tried was a valid DCL-S name except NOT). rpgc reserved 139. Now a keyword is a name everywhere, with IBM's two exceptions for free-form operation codes (IN, OUT, READ, TEST...): at the start of a statement one is that operation, so the assignment needs EVAL (test307), and as a subfield it needs DCL-SUBF (test308). The grammar's kw_name/op_name rules are generated from the lexer's keyword list. MOVE, MOVEL, CALL and GOTO count as operation codes here (IBM i does not have them in free form), since rpgc parses them as free-form statements. TYPE( and CONST( cannot start a call or array element, where they are keywords (SND-MSG TYPE, an enum's CONST).
  • Array indexes are checked: out of range is status 121, not a read or write past the end. A DIM(*AUTO) array grows to the index, up to its maximum.
  • <windows.h> defines IN, OUT, DELETE and CONST as macros; the SQL runtime #undefs them so fields with those names survive on Windows.

Fixed 2026-09-26: formatting and INZ (124 -> 148 of 178 same).

  • %EDITC lays out the field's full edited width for every edit code (1-4, A-D, J-Q, X, Y, Z): commas, zero suppression, CR / trailing / floating minus. Codes 3 and 4 had wrongly attached CR. The code must be a literal or named constant (RNF0355, now enforced; test304). %EDITW uses the operand's decimals, not a fixed two. O-spec edited fields end at their end position.
  • %CHAR of a decimal has no leading zero (.00, -.05) and keeps an expression's scale (+/- the larger, * the sum). FLOAT is +1.500000000000000E+000 (8-byte) or +1.5000000E+00 (4-byte), and so is %EDITFLT.
  • DSPLY of a numeric field shows IBM's form: digits right-adjusted, no decimal point, trailing minus. DSPLY of FLOAT, DATE and TIME works (DATE and TIME did not compile).
  • A data structure without INZ starts as blanks (decision 2026-09-26: match IBM rather than zero-fill). Integer subfields read as blank bytes decode (INT(10) 1077952576), and packed/zoned subfields raise a decimal data error (907) when used. DS-level INZ, INZ(*EXTDFT), INZ(*LIKEDS) and subfield INZ(value) are now supported, free and fixed. Tests 230, 249 and 267 gained INZ. test226 went from "reject" to "run". Every test output involved was checked against IBM's.
  • A data structure declared inside a procedure had no instance at all, so the generated C++ did not compile. It is now declared and initialized where it is declared.
  • INT(n)/UNS(n) keep their digit count (it was dropped), so %EDITC and blank storage know an INT(5) from an INT(10).
  • Not modelled: a blank DATE, TIME, VARCHAR or indicator subfield (they keep their defaults), and comparisons do not raise 907 (arithmetic, assignment and display do).

Load discipline

PUB400 is a free community box run on donated hardware. This is a manual job, never a per-PR gate: serial, objects deleted as they go. (A weekly schedule existed until 2026-09-23; it never committed its results, so it only re-sent the same files each Monday.) Once the oracle cache (step 3) exists, day-to-day CI needs no network at all.


🗳 Community-Requested Features (IBM Ideas Portal)

Source: https://ibm-power-systems.ideas.ibm.com/ideas/?category=7078724330326335155 Append &sort=popular&page=N to walk the list vote-ranked, highest first (~290 ideas over 29 pages as of the last pull).

Last pulled: 2026-10-21 — vote-sorted walk down to a 20-vote floor (61 ideas, pages 1-7 of 29). Eighteen ideas above the floor were in no part of this file; each was judged from its description and, where it might already work, by compiling a program with rpgc.

Why this list exists: the portal is a free, continuously-updated, vote-ranked backlog of what real RPG shops actually want from the language. IBM marks many of the highest-voted entries "Not under consideration" — usually because of ILE/IBM i compatibility constraints this compiler doesn't carry. Those are the highest-value entries here, not the lowest: they're things RPG developers demonstrably want and will never get from IBM.

Maintenance rule (followed by the monthly automated re-pull)

  1. Walk …&sort=popular&page=N from N=1 upward, one page at a time, collecting each idea's title, vote count and IBM status. Stop once vote counts drop below 20 (~6 pages, ~60 ideas). If page 1 comes back not vote-ordered, the sort parameter has broken — say so, and walk unsorted pages 1–10 instead.
  2. Diff against this whole file, not just this section. Skip anything already covered under Implemented Features, Remaining Work, or Not Planned, or already listed in a tier below. Vote counts drift — refresh them in place rather than adding duplicate rows.
  3. For genuinely new ideas, judge fit by reading the actual code (src/lexer.l, src/parser.y, src/codegen.cpp, runtime/, OpenDSPF/) before estimating effort — don't guess. Keep each row's note concrete and specific to this codebase; effort notes here are first-read judgments against the code, not designs.
  4. Sort into the tiers below. Tier 1 (IBM declined it, and it's cheap here) is the highest-value tier, not the lowest — those are things RPG shops demonstrably want and will never get from IBM, because IBM carries ILE/IBM i compatibility constraints this compiler doesn't.
  5. Update the Last pulled date in this section's header.
  6. If nothing changed, don't open a PR — just report that. Otherwise commit to a branch ideas-pull-YYYY-MM and open a PR against main titled TODO: IBM Ideas portal pull YYYY-MM, whose body lists what was added, which vote counts moved, and anything deliberately skipped and why. TODO.md only — the re-pull never changes code.

Tier 1 — IBM declined it, and it's cheap here

The differentiators. Every row is "Not under consideration" at IBM.

Votes Idea Why it's cheap here
93 ✅ RPG block comments (/* … */) — done 2026-10-06 (test350-352) Highest-voted RPG idea in the whole portal. One flex rule in lexer.l (alongside the existing "//".*). Caveat: fixed-format needs separate handling in fixed_reader.cpp, where * in column 7 is already the comment marker.
90 ✅ DSPF definitions from an open format (JSON) — done 2026-10-06 (OpenDSPF test30); PRTF is not built OpenDSPF already compiles DDS → a JSON descriptor. Accepting that JSON as dspfc input is mostly plumbing — shipping the exact thing IBM declined.
40 ✅ %FKEY built-in function — done 2026-10-06 (test353, OpenDSPF test31) rpg_dspf_runtime.h already decodes KEY_F(1)–KEY_F(24) into a function-key number; this is a BIF over state already tracked.
39 ✅ Multiple definitions in one DCL-S — done 2026-10-06 (test354) Grammar-only change.
37 ✅ String interpolation, f'...{expr}...' — done 2026-10-06 (test355-357) Lexer change + desugar to concatenation in codegen. No runtime work.
30 ✅ Procedure inside a procedure — done 2026-10-06 (test358-359) Subroutines already codegen as C++ lambdas — the mechanism exists.
30 ✅ A NOT IN operator — done 2026-10-06 (test360; IN an array fixed too, test361) IN already ships (Test 58).
28 ✅ Regular-expression BIFs: %MATCHES, %FIND, %COUNTMATCHES — done 2026-10-06 (test362-363) std::regex — the compiler already uses it (sql_utils.cpp); generated code would too.
27 ✅ Relax the %SUBST "length exceeds data" error: CTL-OPT OPTION(*NOLENCHK) — done 2026-10-06 (test364-365) Diagnostic policy, not new machinery.
25 ✅ %CHAR with %EDITC formatting: %CHAR(n : *NOZEROSUPPRESS) — done 2026-10-06 (test366-367) Both BIFs already ship; this merges them.
15 ✅ Multiple conditions in one /IF directive: AND, OR, NOT, parentheses — done 2026-10-06 (test369-371; free-form /ELSEIF after an untaken /IF fixed too, test368) Directive handling already lives in the lexer.
13 ✅ *TRUE / *FALSE figurative constants — done 2026-10-06 (test372-373) Aliases of *ON / *OFF for IND.
26 LIKE of a subfield of a nested DS array: LIKE(DS_A.DS_Arr_B.field1) (IBMI-I-2973) LIKE through nested data structures works (test443); through an array of them it is blocked by the known gap of LIKEDS + DIM subfields (see Known gaps).
21 Time format *HM (hours and minutes) for %TIME, %DEC and DDS TIMFMT (IBMI-I-2139) One more entry in the time-format tables (rpg_runtime.h), and in OpenDSPF's TIMFMT for the DDS half.

Tier 2 — "Future consideration" at IBM, small-to-medium here

Votes Idea Note
93 ✅ Conditional (ternary) operator, as %IF(cond : a : b) — done 2026-10-06 (test386-388); ?: itself would clash with : between BIF operands Ties for #1 overall. C++ has it natively, so codegen is a passthrough; grammar is the work.
49 ✅ Keyword parameters in prototyped calls, name => value — done 2026-10-06 (test390-394; a real argument for an *OMIT parameter fixed too, test389)
49 ✅ %SCANRPL limited to *FIRST / *LAST occurrence — done 2026-10-06 (test397; its start and length, ignored until now, test396; an empty scan string, test398) SCANRPL already in codegen.cpp.
48 ✅ OPTION(*UPPER) / OPTION(*LOWER) on parameters — done 2026-10-06 (test400-401; OPTIONS(*TRIM), parsed but never applied, done too, test399)
44 ✅ Dynamic strings: DCL-S text CHAR; — done 2026-10-07 (test402-404) CHAR is already a std::string.
41 ✅ Initialize arrays with %LIST: INZ(%LIST(...)) — done 2026-10-07 (test405-407) %LIST already ships.
39 ✅ %XFOOT over subfields of a DS array: %XFOOT(ds(*).subf) — done 2026-10-07 (test409-410; ds(*).subf itself in SORTA and %LOOKUP, standard RPG, test408)
38 ✅ BIF for comparing data structures: %COMPCORR(ds1 : ds2 {: n}) — done 2026-10-07 (test411-413)
35 ✅ DEPRECATED keyword on procedures — done 2026-10-07 (test415; a call to a procedure defined later with no DCL-PR fixed too, test414) Just a compiler warning.
35 ✅ %LOOKUP searching more than one subfield: %LOOKUP(%KDS(key) : ds(*)) — done 2026-10-07 (test416-417)
29 ✅ %REPEAT BIF — done 2026-10-07 (test418-419)
28 ✅ Default values for *NOPASS / *OMIT parameters: DEFAULT(value) — done 2026-10-07 (test420-421)
27 ✅ %PROGNAME BIF — done 2026-10-07 (test422)
26 ✅ Binary literals, like the hex form: B'10100001' — done 2026-10-07 (test424-425; character operands for %BITAND/%BITOR/%BITXOR/%BITNOT fixed too, test423) Hex literals already ship (Test 112).
22 ✅ Named index of the current FOR-EACH iteration: INDEX(i) — done 2026-10-07 (test427-428; arr(*NEXT) on a DIM(*AUTO) array, standard RPG, done too, test426)
22 ✅ Comparison procedure for SORTA / %LOOKUPxx: %PADDR(proc) — done 2026-10-07 (test429-430)
22 ✅ WHEN-IS-NOT on the newer SELECT, with WHEN-NOT-IN — done 2026-10-07 (test432-433; SELECT operand / WHEN-IS / WHEN-IN itself, standard RPG, test431) Needs SELECT/WHEN-IS first.
18 ✅ %SCANRPL first/last (duplicate filing of the 49-vote entry) — done with it
16 ✅ *EMPTY figurative constant — done 2026-10-07 (test434)
14 ✅ %HEX / %TOHEX / %FROMHEX — done 2026-10-07 (test435)
12 ✅ /MESSAGE compiler directive: /MESSAGE {*WARNING | *ERROR} 'text' — done 2026-10-07 (test436-438)
38 EXTENDS(base) on a DS template: the base's subfields plus its own (IBMI-I-3447) LIKEDS already copies a template's subfields; this is that plus the DS's own, in one declaration.
36 %PARMLEN(parm): the passed length of an OPTIONS(*VARSIZE) parameter (IBMI-I-2956) *VARSIZE is parsed; a character parameter is a std::string, so its length is already at hand.
32 LIKEFLD / LIKE(file.format/field : +n): a field defined from a field reference file (IBMI-I-4705) The .extdesc sidecars (src/extdesc.cpp) already describe external files' fields; this reads a field's type from one.
25 Hide the procedures behind an OVERLOAD from direct calls (IBMI-I-2550) A compile-time check on calls to an OVERLOAD's candidates (the OVERLOAD-with-prototypes bug this pull found is fixed, test445).
22 A case-sensitivity check: every reference spelled as declared (IBMI-I-1842) Names stay case-insensitive; a CTL-OPT keyword makes a reference spelled differently from its declaration a diagnostic.

Larger, but worth their vote count

Votes Idea Note
87 Restrict global-variable use in subprocedures A DCL-PROC keyword plus a codegen-time scope check — the report_semantic_error channel built for MOVE is the right home.
45 FOR-EACH over record-level access RLA over ODBC already ships (Tests 103–108); this is a real but tractable iterator.
38 / 34 Consistent null handling / full NULL support Partly touched by SQL indicator variables; genuinely deep otherwise.
43 Namespaces for exports and prototypes, code::proc() (IBMI-I-1129; also IBMI-I-921, 19 votes) Generated code is C++, which has namespaces, but every name crosses the CALL/EXTPROC and shared-object boundaries too. Not under consideration at IBM.
29 Encapsulation: methods on objects, My.setValue(...) (IBMI-I-2700) Object orientation is a language design project, not a feature. Not under consideration at IBM.
21 Runtime reflection, %VARINFO(var): a variable's type, length, decimals, subfields (IBMI-I-5078) Codegen knows all of it and could emit it as data; the work is the shape of the result. IBM: "functionality already exists" in the compile listing.

Already satisfied here (confirm + document, no work)

Votes Idea Status in this compiler
60 Make %DEC() 2nd & 3rd parms optional Already true — %DEC codegen is static_cast<double>(…) and ignores digits/decimals entirely.
30 Expand the 16,773,104-byte limit within data structures No such limit exists here.
26 Raise maximum variable length to 4GB No such limit exists here — strings are std::string.
30 String templates, `My name is ${ name }` (IBMI-I-1525) f'My name is {name}' (test355-357) does the interpolation; the multi-line backtick form is not provided.
27 DTAARA in subprocedures (IBMI-I-1822) Already compiles and runs: IN/OUT of a data area declared in a DCL-PROC (checked 2026-10-21).
24 XML files over 2GB for XML-INTO/XML-SAX (IBMI-I-1826) No 2GB cap by construction (the document is a std::string); not tested at that size.
23 Indicators told apart from strings in OVERLOAD / OPTIONS(*EXACT) for IND (IBMI-I-4227) IND is its own type here, so OVERLOAD(x_string : x_ind) already picks x_ind for an indicator argument (checked 2026-10-21).
22 IN %SPLIT(...) (IBMI-I-3775) Already works: if r in %split('admin;developer;keyUser' : ';') (checked 2026-10-21).
22 DATA-INTO JSON with no root object (IBMI-I-2055) Already works: rpgc's JSON parser reads {"name": ..., "age": ...} into a DS (checked 2026-10-21).

Reviewed and not planned

Cross-check against ❌ Not Planned below before re-adding any of these.

Votes Idea Why not
76 Rename "ILE/RPG" to "RPG for i" Not a compiler feature.
38 / 1 Native CLOB/BLOB in RPG Already declined under Embedded SQL — Not Planned (LOB support varies wildly by ODBC driver).
36 Control joblog writes with ON-ERROR IBM i runtime.
35 Mixed-case DB2 column names in DS IBM i catalog behavior.
34 EXTNAME/LIKEREC(*NULL) SQL indicator subfields LIKEREC/EXTNAME options are already Not Planned.
27 *NODEBUGSQL control option IBM i compilation directive.
24 Exclude hidden fields from externally-described DS IBM i catalog behavior.
23 DECFLOAT data type Needs a real decimal library — large, standalone.
20 Better UTF-8 (CCSID 1208) support CCSID is already Not Planned; real work, deep.
16 Prohibit changing a program's activation group Activation groups are parsed for compatibility only.
37 SQL table name for an EXTNAME DS (IBMI-I-1893) EXTNAME is already Not Planned.
21 RCV-MSG opcode, message data on ON-EXCP/ON-EXIT (IBMI-I-3974) Receives from the IBM i job log, which has no equivalent here.

❌ Not Planned

These features are IBM i-specific, legacy, or otherwise not applicable:

CTL-OPT USRPRF and VALIDATE — Not Planned

  • USRPRF (adopted authority) and VALIDATE (SQL validation of the ILE program object) belong to IBM i's object model, which a host executable has no counterpart for. Decided 2026-10-27. Like CTL-OPT's other IBM i-only keywords, both are accepted and ignored, so source written for IBM i still compiles.

Embedded SQL — Not Planned

  • SQLTYPE (CLOB, BLOB, XML) — LOB support varies wildly by ODBC driver
  • SQLDA (Descriptor Area) — Complex, rarely used, requires dynamic memory allocation
  • WHENEVER — Legacy error handling; programs should check SQLCODE directly
  • SET OPTION — IBM i compilation directive, no C++ equivalent
  • DESCRIBE — Requires SQLDA support
  • DBCLOB — Double-byte, IBM i specific
  • XML variants (XML_BLOB, XML_CLOB) — IBM i specific

IBM i-Specific

  • %GRAPH, %UCS2, %SHTDN — IBM i encoding/shutdown
  • CCSID — Character set conversion IDs
  • FROMFILE/TOFILE — Compile-time array file keywords
  • SERIALIZE — Serialized procedure access (job locking)
  • ACTGRP semantics — Activation group (parsed for compatibility)
  • INFDS — File Information Data Structure
  • LIKEREC, LIKEFILE, EXTNAME options (*ALL/*INPUT/*OUTPUT/*KEY)
  • ALIGN — Subfield alignment (C++ handles natively)
  • LEN(n) — Explicit DS length
  • NULLIND — Null indicator association
  • OPDESC — Operational descriptors
  • RTNPARM, NOOPT — Procedure/variable keywords
  • %KDS, %FIELDS, %EQUAL, %NULLIND — File/Record BIFs
  • BNDDIR — Binding directory
  • STGMDL, ALLOC(*TERASPACE) — Storage model
  • Open Access / Handler programs
  • *ENTRY — Entry point parameter list
  • %THIS — Java object reference

CTL-OPT Keywords (all accepted/ignored, no implementation)

  • ALWNULL, ALTSEQ, AUT, CHARCOUNT, COPYNEST, CURSYM, DATEDIT, DECPREC
  • DFTNAME, ENBPFRCOL, EXPROPTS, EXTBININT, FIXNBR, FLTDIV, FORMSALIGN
  • GENLVL, INDENT, INTPREC, LANGID, OPTIMIZE, OPENOPT, PRFDTA, REQPREXP
  • SRTSEQ, TGTCCSID, TGTRLS, TRUNCNBR

Declaration Keywords

  • OPTIONS(*VARSIZE/*STRING/*TRIM/*RIGHTADJ/*CONVERT/*EXACT)
  • VARYING, CCSID(n), DTAARA, PERRCD, EXTFMT

Legacy / Fixed-Format

  • RPG cycle processing (detail calc, total calc, LR indicator) — rejected outright, independent of any fixed-format C-spec work below
  • CTDATA, ALT(array), OCCURS/%OCCUR
  • Traditional/extended-factor-2 fixed-column C-spec (modern opcodes in column form) and truly legacy opcodes (ADD, SUB, MULT, DIV, MOVE, COMP, GOTO, TAG, CALL, PARM, PLIST, etc.) are no longer rejected outright — see "Fixed-Format Source Support — Next Steps" above, ranked #1 and #3 respectively, now that the goal is accepting most real fixed-format source rather than just the common cases
  • I-specs, O-specs (fixed-format record I/O) — deferred, not rejected outright; see "Fixed-Format Source Support" above (ranked #4). Real shops overwhelmingly use externally-described files, so this covers little missed value for a lot of engineering (two full column layouts each, program- vs. externally-described, with cross-line state)

Note: /FREE.../END-FREE itself is not rejected — see "Fixed-Format Source Support" above. What's rejected here is reviving the RPG cycle and legacy fixed-column opcode semantics, not the ability for a fixed-format member's C-spec to host modern free-format statements.

Indicator Types (beyond *IN01-*IN99, *INLR, *INH1-*INH9 and *IN)

  • *INKx, *INOx, *INLx, *INUx, *INRT, *INMR

Test Index

Test Description
01 Hello World
02 Arithmetic
03 Types
04 BIFs
05 IF
06 Loops
07 Select
08 Procedures
09 Data Structures
10 Expanded BIFs
11 Error Reporting
12 Monitor
13 Subroutines
14 Indicators
15 DOU Loop
16 /COPY Include
17 Named Constants
18 Date/Time
19 Math BIFs
20 Memory BIFs
21 %PARMS
22 %STATUS/%ERROR
23 RESET/CLEAR
24 %MAX/%MIN
25 DCL-F
26 Pointers
27 Arrays
28 Conditional Compilation
29 DCL-SUBF/DCL-PARM
30 LIKE
31 LOOKUP and SORTA
32 EDITC/EDITW
33 REPLACE
34 CHECK/CHECKR
35 EVAL-CORR
36 DS Params
37 *INZSR
38 CTL-OPT
39 Figurative Constants
40 EVALR/LEAVESR
41 String/Math BIFs
42 ON-EXIT
43 STATIC
44 ALLOC/DEALLOC
45 Array BIFs
46 OPTIONS(*NOPASS)
47 TEST Opcode
48 NOMAIN Module
49 EXTPROC/IMPORT
50 Numeric BIFs
51 String BIFs
52 Array BIFs
53 Date/Time BIFs
54 Memory/Pointer BIFs
55 Figurative Constants
56 MAIN(procname)
57 DATFMT/TIMFMT
58 FOR-EACH and IN
59 %PASSED/%OMITTED
60 Data Types
61 No **FREE
62 OVERLAY/POS
63 PREFIX
64 OPTIONS(*OMIT)
65 DFTACTGRP/ACTGRP
66 *PSSR
67 *PSSR Error
68 Bitwise & Power
69 %SCANR
70 %EDITFLT & %UNSH
71 Enum & indicator
72 DIM(*VAR)
73 Date Formats
74 %CONCAT
75 %TLOOKUP & %ELEM
76 DS DIM(*VAR)
77 Embedded SQL
78 SQL in Procedures
79 SQL Core Statements
80 SQL Cursors
81 Dynamic SQL
82 SQL Advanced
83 SQL Multi-row
84 SQL Connect
85 %GETENV
86 SQL End-to-End (DS FETCH, cursors)
87 XML-INTO (direct mode, %XML, case options)
88 XML-INTO array DS (fixed DIM, DIM(*VAR), path option)
89 XML-INTO path option + nested DS (LIKEDS subfields)
90 PSDS Basic
91 PSDS + MONITOR
92 Data Area *LDA round-trip
93 Data Area named
94 Extender (H) Half-Adjust
95 Extender (E) Error
96 DA Status 401 (not found)
97 DA Status 415 (cannot read)
98 DA Status 413 (cannot write)
99 DATA-INTO JSON parsing
100 DATA-GEN JSON generation
101 *USER figurative constant
102 SND-MSG
103 RLA CHAIN / %FOUND
104 RLA READ sequential
105 RLA WRITE/UPDATE/DELETE
106 RLA SETLL/READE
107 SQL via rpgc.conf (no CONNECT)
108 RLA via rpgc.conf (no CONNECT)
109 SQL indicator variables
110 OVERLOAD procedures
111 %ELEM(*ALLOC)/%ELEM(*KEEP)
112 DATA-INTO CSV parsing (%PARSER('CSV'))
113 DATA-GEN CSV generation (%PARSER('CSV'))
114 DATA-INTO/GEN explicit %PARSER('JSON')
115 DUMP opcode
116 Fixed-format: H+D specs only
117 Fixed-format: H+D+C(/free) hello world
118 Fixed-format: H+F+D+C DISK CHAIN, full Phase 1 milestone
119 Fixed-format: syntax error inside /free
120 Fixed-format: VARCHAR standalone field (VARYING)
121 Fixed-format: VARCHAR DS subfield (VARYING)
122 Fixed-format: DISK CHAIN with a VARCHAR key
123 Fixed-format: DIM(n) array of DS
124 Fixed-format: subfield OVERLAY/POS
125 Fixed C-spec: IF/ELSEIF/ELSE/ENDIF
126 Fixed C-spec: DOW/DOU/ENDDO
127 Fixed C-spec: FOR/ENDFOR (TO/DOWNTO/BY)
128 Fixed C-spec: SELECT/WHEN/OTHER/ENDSL
129 Fixed C-spec: BEGSR/EXSR/ENDSR
130 Fixed C-spec: EVAL/EVALR/CALLP/RETURN/LEAVE/ITER
131 Fixed C-spec: CLEAR/RESET/DSPLY/SORTA
132 Fixed C-spec: MONITOR/ON-ERROR/ENDMON
133 Fixed C-spec: Extended-Factor-2 continuation across lines
134 Fixed C-spec: mixed with an explicit /free block
135 Fixed C-spec: reject non-blank control level
136 Fixed C-spec: reject misaligned cond indicator
137 Fixed C-spec: reject deferred opcode (CHAIN) with distinct message
138 Fixed C-spec: reject legacy opcode (ADD)
139 Fixed-format /COPY: D-spec copybook
140 Fixed-format /INCLUDE: C-spec copybook mid-run
141 Fixed-format /COPY: nested copybooks
142 Fixed-format /COPY inside an explicit /free block
143 Fixed-format /COPY: missing file rejected
144 Fixed C-spec: CHAIN by key, %FOUND
145 Fixed C-spec: READ sequential, %EOF
146 Fixed C-spec: WRITE/UPDATE/DELETE
147 Fixed C-spec: SETLL/READE
148 Fixed C-spec: GOTO/TAG backward loop + forward skip
149 Fixed C-spec: GOTO/TAG inside BEGSR
150 Fixed C-spec: ADD/SUB/MULT/DIV (both Factor 1 forms)
151 Fixed C-spec: Z-ADD/Z-SUB
152 Fixed C-spec: reject GOTO inside explicit /free block
153 Fixed C-spec: reject deferred legacy opcode (CALL)
154 Fixed I-spec: single record type, sequential READ
155 Fixed I-spec: multi record type dispatch
156 Fixed I-spec: field indicators (plus/minus/zero)
157 Fixed I-spec: UPDATE rewrites record in place
158 Fixed O-spec: field placement + edit code
159 Fixed I-spec: zone and digit record identification codes
160 Fixed I-spec: reject matching fields (M1)
161 Fixed D-spec: per-subfield DIM(n)
162 Fixed D-spec: per-subfield LIKE(field)
163 Free-format: per-subfield LIKE/DIM declaration + access
164 Fixed C-spec: conditioning indicators
165 Fixed C-spec: reject cond ind on block opcode
166 Fixed C-spec: reject unsupported cond indicator
167 Fixed C-spec: AN/OR indicator groups
168 Fixed C-spec: reject orphan AN line
169 Fixed C-spec: reject dangling cond ind line
170 Fixed C-spec: MOVE/MOVEL character move
171 Fixed C-spec: reject MOVE date to time
172 Fixed C-spec: reject MOVE format, no date operand
173 Free-format: reject MOVE (fixed-format only)
174 CALL callee module (NOMAIN)
175 Fixed C-spec: CALL/PARM program call
176 Fixed C-spec: reject PLIST with no PARM
177 Fixed C-spec: reject dynamic CALL name
178 Fixed C-spec: reject PARM without CALL
179 Free-format: reject CALL (fixed-format only)
180 Fixed C-spec: DATA-INTO / SND-MSG
181 Fixed C-spec: reject ON-EXIT (proc-only)
182 Fixed C-spec: CASxx chain + CABxx branch
183 Fixed C-spec: reject orphan ENDCS
184 Fixed C-spec: reject unclosed CASxx group
185 Fixed C-spec: reject bad CASxx mnemonic
186 Fixed C-spec: COMP resulting indicators
187 Fixed C-spec: reject COMP with no indicators
188 Fixed C-spec: embedded SQL (C/EXEC SQL)
189 Fixed C-spec: reject orphan C/END-EXEC
190 Fixed C-spec: reject unterminated EXEC SQL
191 Fixed C-spec: MOVE numeric to numeric
192 Fixed C-spec: MOVE character to numeric
193 Fixed C-spec: MOVE numeric to character
194 Fixed C-spec: reject MOVE on float field
195 Fixed C-spec: reject MOVE decimal literal
196 Fixed C-spec: MOVE characters by EBCDIC digit portion
197 Fixed C-spec: named PLIST
198 Fixed C-spec: PARM factor 1/factor 2
199 Fixed C-spec: reject *ENTRY with NOMAIN
200 Fixed C-spec: reject undefined PLIST name
201 Fixed C-spec: reject duplicate PLIST name
202 Fixed C-spec: reject literal PARM result
203 *ENTRY callee module (ADDTWO)
204 Fixed C-spec: *ENTRY PLIST call
205 Fixed C-spec: *ENTRY PARM factor 2 copies back on return
206 Fixed C-spec: reject undeclared *ENTRY parm
207 Fixed C-spec: reject CALL naming *ENTRY
208 Fixed C-spec: MOVE date conversions (Figure 287)
209 Fixed C-spec: MOVE date/time without separators (Figure 288)
210 Fixed C-spec: MOVE timestamp (Figure 289)
211 Fixed C-spec: MOVE date alignment/truncation
212 Fixed C-spec: MOVE invalid date -> 112
213 Fixed C-spec: reject MOVE *JOBRUN
214 Fixed C-spec: reject MOVE time *USA to numeric

Compiler Directives — conditionals in fixed format ✅ (fixed 2026-09-22)

The Implemented Features list claims /DEFINE, /UNDEFINE, /IF DEFINED, /IF NOT DEFINED, /ELSEIF, /ELSE and /ENDIF. They used to work in free-format source only. In fixed-format source they were silently dropped and both branches compiled — no error, no warning. Cause: fixed_reader.cpp's expandCopyDirectives() flattened /COPY and /INCLUDE itself and never routed anything else through the lexer's conditional machinery.

The fix. That pass (expandMember) now resolves every directive, evaluating conditionals interleaved with copy expansion:

  • An /IF can guard a /COPY; an inactive /COPY is never opened.
  • A copy member can contain /IF and /DEFINE.
  • /DEFINE is global to the compile, as on IBM i.
  • A nested group inside an inactive branch stays inactive.
  • /ELSEIF takes only the first true branch.
  • /EOF ends the current member only.
  • An /IF group must close in the member that opened it. Stray or unclosed directives are errors, and errors inside a copy member name the member.
  • Directive lines and inactive lines become empty lines, so the including member's own line numbers stay put.

Inside /FREE blocks. Directives there are resolved by the same pass. That fixes the "conditionals inside a /FREE block are a hard syntax error" item below and keeps one define set for the whole compile. This includes /COPY: a member spliced into a /FREE block drops its own **FREE line and is shifted to position 8, since its code may start in position 1, which the block's RNF0257 rule would reject.

/EOF in free format. A /COPY member ending in /EOF used to terminate the whole compile (yyterminate() instead of popping the include buffer). It silently discarded every line of the including program, with exit status 0. /EOF now pops the buffer inside a member.

Tests 232 (fixed-format directives, with tests/fixed_copybook_cond.rpgle), 233-234 (stray /ENDIF, /IF left open in a copy member) and 235 (/EOF in a free-format copy member).

Still open:

  • No -D flag on rpgc, and nothing predefined (*CRTBNDRPG, *ILERPG, *VxRxMx). A symbol can only be defined by the source that uses it, which removes most of the reason to have /IF at all. IBM has DEFINE() on CRTBNDRPG.
  • /SET is accepted and dropped, so /SET DATFMT(*DMY) changes nothing with no diagnostic.

Found by writing an interactive program (2026-09-01)

Writing OpenDSPF/tests/TEST27_CUSMNT.rpgle — a full subfile maintenance program — turned these up before it would compile at all. The first two are the serious ones.

A subprocedure cannot see a global. ✅ Fixed 2026-09-01 — see the section below. Every RPG global was emitted as a local of main(), and subprocedures as free functions above it, so any reference from a subprocedure failed to compile:

DCL-S gcount INT(5);
DCL-PROC Bump;
  DCL-PI Bump; END-PI;
  gcount = gcount + 1;   // error: use of undeclared identifier 'GCOUNT'
  *IN10 = *ON;           // error: use of undeclared identifier 'rpg_indicators'
END-PROC;

In RPG, anything declared in the main source section is global to every subprocedure in the module. As it stands subprocedures are usable only as pure functions over their parameters. It rules out WORKSTN programs entirely — EXFMT and READC move data through the record format's fields, which are globals — which is why TEST27 is written with subroutines.

EXSR only resolves backwards. Subroutines become auto sr_X = [&](){} lambdas emitted in source order inside main(), so a subroutine may only call one defined textually above it. RPG places no ordering requirement on subroutines, and the conventional layout — mainline first, then subroutines in the order the reader meets them — is exactly the one that fails. Mutual recursion is impossible. TEST27 orders its subroutines leaf-first to work around it.

Character comparison does not blank-pad. ✅ Fixed 2026-09-22 (Test 229). It compiled to plain std::string ==, so if myfield = ' ' — the standard way to ask whether a field is blank — was false for any CHAR(n) with n > 1 that holds blanks. *BLANKS in a comparison emitted an undeclared RPG_BLANKS and did not compile.

The codegen has no expression types, so the fix lives in the runtime. Every relational operator now emits rpg_eq/rpg_ne/rpg_lt/rpg_gt/rpg_le/ rpg_ge. These templates compare two character operands (std::string or std::string_view only, so a pointer against *NULL stays a pointer comparison) with the shorter one padded with blanks, and fall back to the plain C++ operator for anything else. *BLANKS/*ZEROS/*HIVAL/*LOVAL are now runtime RpgFigConst objects. In a comparison they take the other operand's length, so *ZEROS beside a CHAR(3) is '000' and beside a number is 0. %LOOKUP/%LOOKUPxx/%TLOOKUPxx and IN use the same helpers, so a CHAR(3) array searched for 'AB' now finds it.

Still open, found alongside:

  • A hex literal containing X'00' was truncated at the NUL. ✅ Fixed 2026-09-22 (Test 242). It was lost at three layers:

    • the lexer's C-string token (X'C1004142' became 'A')
    • the parser
    • the emitted std::string("…")

    A hex literal holding a 00 byte now travels as its hex digits behind a marker, decoded by rpg_decode_lexed_string (ast.h) where it becomes a value, and is emitted with its length. Found alongside:

    • Bytes were emitted as greedy \xNN escapes, so a control byte followed by a hex-digit character merged into one wrong byte (X'0A' + 'B'). Both emitters now use octal.
    • The two *ALL code paths pasted the pattern raw into the C++, so a " or \ in it broke the build.
    • rpg_all looped forever on an empty pattern.
  • A figurative constant anywhere other than a comparison or an assignment still doesn't compile. Examples are a concatenation ('x' + *BLANKS) and a BIF argument.

  • *HIVAL/*LOVAL against a numeric are ±DBL_MAX, not the extremes of the field's declared digits. That only matters for equality.

  • Ordering uses ASCII, not EBCDIC, collation (as before). Digits sort below letters here, above them on IBM i.

DS subfields lose their declared attributes. ✅ Fixed 2026-09-22 (Test 230). A CHAR(n) subfield was emitted as an uninitialised std::string, so it started empty rather than as n blanks. A PACKED(9:2) subfield's scale was invisible to codegen, so %CHAR(ds.field) printed 1250.000000. Both came from codegen keying declarations on bare names (var_types_/var_lengths_/var_decimals_), which a reference through a DS never matches.

  • Subfields now get the same initial value as a standalone field of their type, as a default member initializer. That covers each DS-array element, each LIKEDS copy, and DIM'd CHAR subfields (via rpg_filled_array).
  • CodeGen::attrsOf(expr) returns the declared type, length, digits and scale for a bare name or ds.f, ds(i).f, or ds.nested.f through LIKEDS. %CHAR, %SIZE, EVAL(H) rounding, and *BLANKS/*ALL'x' assignment use it now.
  • *BLANKS assigned to a CHAR subfield used to be treated as a numeric target, emitting = 0 into a std::string.
  • Seven goldens (tests 9, 29, 86-89, 99) had recorded the six-decimal output and were regenerated. Every changed line matches its subfield's declared scale.

Found alongside, all verified:

  • No CHAR/PACKED target keeps its declared length or scale on assignment. ✅ Fixed 2026-09-22 (Test 231) — see "Assignment fits the declaration" below.
  • The subfield grammar is a hand-enumerated list covering only INT, CHAR, VARCHAR and PACKED (with a few keyword combinations each). So ZONED, IND, DATE, UNS, FLOAT and others are syntax errors in a DS. This subsumes the IND entry below. ✅ Fixed 2026-09-22 (Test 249). ds_field is now one rule: a name, a type from param_type (shared with procedure parameters) or LIKEDS/LIKE, then any of POS/OVERLAY/DIM. Every scalar type is accepted, and each starts at its type's initial value (IND off, UNS/FLOAT zero, POINTER null). Still open: INZ on a subfield, which DSField has nowhere to store (test 226 rejects it rather than dropping it).
  • TEMPLATE on DCL-DS is a syntax error. ✅ Fixed 2026-09-22 (Test 251). The DCL-DS header is now any order of QUALIFIED/TEMPLATE/DIM/PREFIX, with LIKEDS and PSDS/SDS in slots of their own, replacing 18 fixed sequences. A TEMPLATE DS emits its struct type for LIKEDS but no instance.
    • Dropped: the fieldless DCL-DS x PSDS; (no END-DS), which isn't valid RPG. It made a PSDS's first subfield ambiguous with a statement once the header was generalized; dropping it removed the grammar's reduce/reduce conflict (2 S/R remain).
    • Found alongside: PSDS subfields were filled raw at program start, so a CHAR(10) program-name field was 4 bytes long; they are fitted now.
  • A nested a.b.c is readable but is a syntax error as an assignment target. ✅ Fixed 2026-09-22 (Test 252). The dotted target forms are one left-recursive chain (eval_target DOT IDENTIFIER [ (idx) ]), so a.b.c, ds(i).sub.f and a.sub.arr(i) all assign, and are fitted to the nested field's declaration. The expression side's ds.field(idx) likewise takes any chain of names as its base now. Still open: an element of a DIM subfield under an indexed base, items(1).f(2).
  • A standalone CHAR(n) DIM(m) array starts as empty strings, not blanks. ✅ Fixed 2026-09-22 (Test 243). Found alongside it, and worse: a numeric array declared inside a procedure was a local std::array with no initializer, so its elements held leftover stack contents (the test's negative control read 448, 64 × a previous call's 7s). Standalone arrays now start blank-filled (CHAR) or zeroed (everything else), and DIM'd numeric subfields are zeroed too. INZ on an array and CHAR(n) DIM(*VAR:m), syntax errors when this was written, were fixed with the DCL-S rewrite (Test 250).

Smaller, all verified:

  • DCL-PROC with no DCL-PI is a syntax error. IBM allows the interface to be omitted for a procedure with no parameters and no return value. ✅ Fixed 2026-09-22 (Test 248), with and without ON-EXIT.

  • DCL-PI *N; is a syntax error. *N is IBM's standard unnamed-interface form and the recommended one in modern code. ✅ Fixed 2026-09-22 (Test 244). *N is lexed as a name only as the first token after DCL-PI (the PI_NAME lexer state); elsewhere total*N is still multiplication. Still open: a top-level DCL-PI (a free-format program's own entry parameters) isn't in the grammar at all; only *ENTRY PLIST is.

  • A bare procname(args); statement is a syntax error; CALLP is required. IBM made CALLP optional in free form. ✅ Fixed 2026-09-22 (Test 245). Only the one-argument form failed: proc(x); reads like the start of arr(x) = …;, and the parser committed to the assignment. A dedicated one-argument call rule lets the token after ) decide. proc(); and proc(a : b); already parsed.

  • An unqualified DS subfield cannot be referenced by its bare name — the subfield is emitted inside the struct but referenced unqualified. ✅ Fixed 2026-09-22 (Tests 253-254). visit(DclDS) records each subfield of a DS without QUALIFIED (not an array or a TEMPLATE), under its PREFIXed name where there is one. A bare name that isn't a standalone field resolves to DS.field, in emitted code and in attrsOf, so it is fitted like any subfield. The table is scoped per procedure. Fixed-format data structures, unqualified by default, are the main beneficiary. Still open: an unqualified subfield used as an SQL host variable (:name) is emitted by its bare name.

  • IND is not accepted as a DS subfield type. ✅ Fixed 2026-09-22 (Test 249) — see the subfield-grammar entry below.

  • CONST on a procedure-interface parameter is a syntax error. ✅ Fixed 2026-09-22 (Tests 246-247). The parameter grammar spelled out 30 alternatives per rule, one per type × keyword combination, and none had CONST. It is now one param_decl rule: a name, a type (param_type, which also gained UNS, ZONED, IND, DATE, TIME, TIMESTAMP and POINTER) or LIKEDS, then any of VALUE/CONST/ OPTIONS(...) in any order. A CONST parameter binds as const T&, so a literal or an expression can be passed, and the body sees a const local fitted to the declared type (CHAR(10) CONST passed 'AB' holds 'AB' plus eight blanks). Assigning to one is an RPG diagnostic. Found alongside:

    • A call with every *NOPASS parameter omitted, M(), generated M(, 0, 0) (fixed).
    • OPTIONS(*NOPASS) on a by-reference parameter, newly expressible, has no codegen yet (a C++ reference cannot be absent) and now gets a clear diagnostic; VALUE and CONST ones work.
    • Passing a real variable to an *OMIT parameter, rather than *OMIT itself, is untested and likely broken (the parameter is a pointer and call sites don't take the argument's address).
  • INZ on a ZONED standalone is a syntax error, though ZONED itself works. ✅ Fixed 2026-09-22 (Test 250). DCL-S is now one rule — a type, then any of its keywords in any order — replacing 48 alternatives that each paired one type with a hand-picked keyword subset. Newly expressible combinations got codegen:

    • INZ on an array fills every element.
    • A varying array's %ELEM growth uses the element's initial value (blanks for CHAR).
    • Every INZ value is fitted to its declaration: INZ('ABCDEFG') on a CHAR(5) is 'ABCDE'. Tests 3 and 115 had recorded unpadded CHAR initial values.

    Found alongside: the typed date literal D'2026-09-22' is a syntax error.

  • EXFMT and READC are absent from the fixed-format C-spec opcode set, so no legacy interactive program can be compiled in native fixed columns at all.

  • %EDITC(p:'3') emits thousands separators. Worth checking against SC09-2508 — edit code 3 should suppress them.

Module globals now live at file scope ✅ (2026-09-01)

RPG scopes everything declared in the main source section to the whole module — the mainline and every subprocedure see it. rpgc emitted those declarations as locals of main() and subprocedures as free functions above it, so a subprocedure referencing any of them failed to compile with use of undeclared identifier. That ruled out modular RPG generally and WORKSTN programs specifically, since EXFMT and READC move data through the record format's fields, which are globals.

visit(Program&) now emits standalone fields, named constants, DCL-F declarations, data-structure instances and the indicator array into an anonymous namespace ahead of the procedures, instead of into main(). The anonymous namespace is what keeps them internal to the module, which matters as soon as two modules are linked together — two members that each declare a field called I must not collide, while genuinely shared variables keep going through EXPORT/IMPORT at true file scope.

Three details the change had to get right:

  • Declarations only. Some declarations also emit something that has to run — a DIM(*VAR) array's reserve, a PSDS's field initialisation. Those are deferred into the top of main() rather than hoisted, via deferred_init_.
  • The _dspf.h include moved. visit(DclF&) used to emit #include "NAME_dspf.h" from the middle of main()'s body. It now goes with the other includes: the declarations it types are at file scope, and a header that itself includes <string> must not end up inside the anonymous namespace.
  • *ENTRY PLIST parameters resolve earlier. They become C++ reference parameters and their D-specs are suppressed rather than declared, so that has to be settled before any declaration is emitted.

Guarded by test227, which reads and writes a scalar, an array, a DS subfield, a named constant and an indicator from two subprocedures and has the mainline observe the results. Negative control: with the change stashed it fails to compile, exactly as before.

Also from the same finding, and now fixed too: EXSR resolves in either direction — see the next section.

EXSR resolves in either direction ✅ (2026-09-01)

Subroutines are emitted as auto sr_X = [&](){ ... }; lambdas, in source order, inside main(). A lambda is not in scope until its declaration is reached, so a subroutine can only call one defined textually above it:

BEGSR DoOpts;
  EXSR FindCus;    // error: use of undeclared identifier 'sr_FINDCUS'
ENDSR;
BEGSR FindCus;
  ...
ENDSR;

RPG places no ordering requirement on subroutines at all, and the conventional layout — mainline first, then subroutines in the order a reader meets them — is exactly the one that fails. Mutual recursion is impossible. OpenDSPF/tests/TEST27_CUSMNT.rpgle has its subroutines ordered leaf-first purely to work around this, which is not how anyone writes RPG.

Hoisting the globals to file scope removed the reason subroutines had to be lambdas: they captured [&] to reach main()'s locals, and there are no longer any locals worth reaching. Each of the mainline's subroutines is now an ordinary static void sr_X() at file scope — the whole set forward-declared before the procedures, defined after them so a subroutine may also call a procedure — and both the ordering restriction and mutual recursion fall out for free.

A subroutine inside a DCL-PROC is a different thing: scoped to that procedure and needing its locals, so that case keeps the lambda. *INZSR and *PSSR keep their special handling — *INZSR auto-called before the first executable statement, *PSSR invoked from main()'s catch block.

Guarded by test228: a driver subroutine calling two leaves defined below it, plus a Ping/Pong pair that call each other, which no ordering of definitions can satisfy. Without the change it fails to compile with use of undeclared identifier 'sr_LEAF'.

Fixed 2026-09-29: RETURN inside a subroutine returned only from the subroutine; IBM returns from the procedure, or ends the program. It now records the return (and its value) and the EXSR that called the subroutine returns in turn. A procedure's *PSSR doing RETURN -1 had returned garbage.

Assignment fits the declaration ✅ (2026-09-22)

Declarations were right, but assignment was a plain C++ assignment. A CHAR(5) assigned 'AB' held two bytes and one assigned 'ABCDEFG' held seven. A PACKED(9:2) assigned 1.239 held 1.239, which %CHAR then rounded to 1.24; RPG truncates to 1.23. Concatenation, %LEN and %SUBST all saw the wrong value.

The fix. EVAL wraps the assigned value whenever CodeGen::attrsOf knows the target's declaration:

  • CHAR(n) → rpg_fit_char: pad with blanks, or truncate on the right.
  • VARCHAR(n) → rpg_fit_varchar: truncate past n, never pad.
  • PACKED/ZONED → rpg_trunc_dec: drop decimals past the scale, unless (H) asked for rounding.

That covers standalone fields, array elements, subfields, DIM'd subfield elements, and elements of a DS array. EVALR benefits too, since it right-adjusts to the target's (now always declared) length. rpg_trunc_dec nudges by a few ULPs before truncating, so 0.29 (stored as 0.28999…) stays 0.29. A first version used a relative epsilon, which pushed 99999999.99 past a whole unit (Test 221 caught it).

What it took alongside:

  • Procedure declarations are now scoped. The attribute tables were flat maps, so a procedure-local sharing a global's name replaced the global's entry for the rest of the compile. In test 8, main's RESULT INT(10) was fitted as the procedure's RESULT VARCHAR(100) and failed to compile. visit(DclProc) now saves and restores them.
  • LIKE copies digits and decimals, not only type and length. A LIKE copy of a PACKED(9:2) had been printing, and would now have been truncated, as a whole number. Test 30's golden had recorded 3 for 3.14.
  • SQL string parameters are bound without trailing blanks. A padded key 'C002 ' no longer matched 'C002' on SQLite. DB2 compares character values blank-padded, so trimming reproduces its result. The cost: a VARCHAR host variable deliberately ending in blanks loses them.
  • The READE/READPE key check uses rpg_eq instead of ==.

53 goldens were regenerated:

  • 42 differ only in trailing blanks from DSPLY of a now-padded CHAR field.
  • 11 are corrections: %LEN of a CHAR(20) is 20, LIKE/DIM scales print correctly, and report columns now include their fields' padding, as they do on IBM i.

Still open, all verified or read from the code:

  • Only EVAL/EVALR fit. ✅ Mostly fixed 2026-09-22 (Tests 236, 237). Every declared store now goes through CodeGen::fitValue:

    • Procedures: parameters are registered in the attribute tables (inside the procedure's scope), so EVAL into one fits. VALUE parameters are fitted on entry; by-reference ones are the caller's storage and are left alone. RETURN fits to the declared return type, which the parser used to discard — pi_return_type now carries the length and scale, so every interface no longer comes out with 0 for both. A call's value also has its return type as far as attrsOf is concerned, so %CHAR(proc()) formats at the declared scale.
    • SQL: host variables filled by SELECT INTO/FETCH (including multi-row FETCH and DS targets) are fitted after the fetch.
    • XML-INTO/DATA-INTO (XML, JSON and CSV) fit each subfield as it is filled.
    • DSPLY has no response-variable form here at all, so there was nothing to fit (an earlier note listing it was wrong).

    Record-level access ✅ (fixed 2026-09-22, Test 238). Reads into externally described fields (CHAIN/READ/READE and friends) assigned the raw column value into an unsized std::string. The fix:

    • .extdesc now records each column's kind, as an optional trailing token: char (fixed-length — padded on IBM i), varchar, decimal, int, float or datetime. An older rpgc reads three tokens and ignores it; this rpgc reading an older cache sees no kind and declares and reads the field exactly as before.
    • A char field is declared blank-filled. char/varchar/decimal/ int fields are registered in the attribute tables, so reads, EVAL and %CHAR all use the column's shape. A float column is never truncated, since the scale a driver reports for one isn't a scale.
    • The live refresh reads SQLColumns' TYPE_NAME as well as DATA_TYPE. SQLite's ODBC driver reports CHAR(6) as SQL_VARCHAR (SQLite has no fixed-length type) and DECIMAL(7,2) as a 2-byte string, but TYPE_NAME carries the declaration. On a driver that reports types exactly, the two agree and nothing changes.
    • The 13 committed test caches gained their kinds. Regenerating four of them live from SQLite produced the same kinds.
  • Integer-digit overflow isn't detected. ✅ Fixed 2026-09-22 (Tests 239-241).

    • What was wrong: RPG EVAL of 123456 into a PACKED(5:0) is RNX0103 (status 103), and it used to store the value. Division by zero, listed as done in the status table, wasn't raised either: a / 0 produced inf and carried on, and %DIV(x:0) crashed with SIGFPE.
    • The mechanism: rpg_raise sets %STATUS and throws RpgError, which MONITOR/*PSSR catch. Unmonitored, a terminate handler prints the RNX message and exits 1, as a batch job ends on IBM i.
    • Where it is raised: EVAL, RETURN and VALUE parameters into PACKED/ZONED raise 103 when the integer digits don't fit, after truncation or (H) rounding (999.995 half-adjusted into a PACKED(5:2) is 1000.00 and raises). INT/UNS raise 103 outside their 4-byte range, since the declared width isn't tracked. /, %DIV and %REM by zero raise 102.
    • The fixed-format arithmetic opcodes (ADD, SUB, MULT, DIV, Z-ADD, Z-SUB) do not raise. SC09-2508 has them drop the result's high-order digits, and they are transpiled to EVAL, so the transpiler marks that EVAL with an internal (T) extender, which parser.y rejects in hand-written source. Test 221's golden had recorded 99,999,999.99 in a 9S 2 field via Z-ADD; it is now 9,999,999.99, as IBM would give.
    • Stores from SQL, XML and file reads still only fit length and scale. On IBM i those report a too-small target through their own channels (SQLCODE and so on), not status 103.
    • Found alongside: integer literals beyond int range were atoi'd into an int and silently wrapped (3000000000 became -1294967296). The lexer now returns such a literal as a decimal literal.
    • Still open: INT(3)/INT(5)/INT(20) all compile to a 4-byte int, so their own ranges aren't enforced (INT(20) can't even hold its range). Integer arithmetic that overflows int before the assignment still wraps in C++.
  • Float literals are emitted with 10 fixed decimals (setprecision(10)), so 99999999.99 becomes 99999999.9899999946 in the generated C++. Truncation absorbs it, but the literal itself is lossy.

  • DCL-PI *N is still a syntax error. Test 231 names its interface.