From 1874386fdaf6ba052ea09e5666850172a4deaecd Mon Sep 17 00:00:00 2001 From: PYDuquesnoy Date: Mon, 7 Sep 2026 12:20:56 +0200 Subject: [PATCH] fix(compile): one error assembly, cross-checked against IRIS's own count (#199) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `iris_doc{mode:put, compile:true}` was the third copy of the compile-console loop and the #80 fix reached only two of them. It matched `ERROR ` (space) while this build prefixes per-method diagnostics with `ERROR:` (colon). Measured live, IRIS 2026.1 (Build 235U), namespace APP, class Zz.MSG.Undercount — three methods, three undefined macros, same class through both tools: iris_compile Detected 13 errors -> 14 entries (13 + the status wrapper) iris_doc put Detected 13 errors -> 1 entry The one entry that survived was `#5123 Unable to find entry point for method 'M1'` — a cascade. ZzUndefined0/1/2, named plainly by IRIS on three console lines, never reached the response, and the standard hint ("fix the first reported error, later ones are cascades of it") pointed at the one line that was definitely not the cause. Two fixes, because the copy and the missing check are separate faults: - `compile_error_list(body, console)` is now the single assembly for the string consumers (`compile_document`, `iris_doc`). A copy cannot fall behind if there is no copy. It also carries the `diag_stands_in_for` rule, so the multi-line status wrapper stops swallowing the first method's own entry. - `detected_error_count(console)` reads IRIS's own `Detected N errors` tally — computed by the compiler, not by our parser — and every compile response compares its list against it. Short lists get `errors_incomplete: true`, `errors_detected_by_iris`, `errors_reported`, and a hint that replaces the cascade advice and names the field holding the rest. The invariant is `reported >= detected`, not equality: the status.errors wrapper is an entry IRIS does not count. Several tallies take the largest, not the sum, so a grand-total line can never manufacture a false undercount. Verified on the wire on this build: the fixed path returns 13 of 13 with no flag; with the parser deliberately reverted to the space-only prefix the same call returns 1 of 13 and the server itself reports errors_incomplete. Refs #80 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GRUAp882PyFWMuQAw1kAzM --- .../iris-agentic-dev-bin/src/cmd/compile.rs | 11 +- .../src/iris/connection.rs | 29 +- crates/iris-agentic-dev-core/src/tools/doc.rs | 38 +- crates/iris-agentic-dev-core/src/tools/mod.rs | 346 +++++++++++++++++- 4 files changed, 382 insertions(+), 42 deletions(-) diff --git a/crates/iris-agentic-dev-bin/src/cmd/compile.rs b/crates/iris-agentic-dev-bin/src/cmd/compile.rs index 911e142..28922bd 100644 --- a/crates/iris-agentic-dev-bin/src/cmd/compile.rs +++ b/crates/iris-agentic-dev-bin/src/cmd/compile.rs @@ -185,13 +185,20 @@ fn compile_result_to_json(r: &CompileResult, target: &str, namespace: &str) -> s .iter() .map(|e| serde_json::json!({"severity":"error","text":e})) .collect(); - serde_json::json!({ + let mut out = serde_json::json!({ "success": r.success(), "target": target, "namespace": namespace, "errors": errors, "console": r.console, - }) + }); + iris_agentic_dev_core::tools::note_error_undercount( + &mut out, + r.detected_error_count(), + r.errors.len(), + "console", + ); + out } fn output_result(result: &serde_json::Value, format: &str) { diff --git a/crates/iris-agentic-dev-core/src/iris/connection.rs b/crates/iris-agentic-dev-core/src/iris/connection.rs index 6195e48..70a2653 100644 --- a/crates/iris-agentic-dev-core/src/iris/connection.rs +++ b/crates/iris-agentic-dev-core/src/iris/connection.rs @@ -244,6 +244,13 @@ impl CompileResult { pub fn success(&self) -> bool { self.errors.is_empty() } + + /// IRIS's own `Detected N errors during compilation` count, when the console carried it. + /// Cross-check it against `errors.len()` before presenting this list as complete — + /// see [`crate::tools::detected_error_count`]. + pub fn detected_error_count(&self) -> Option { + crate::tools::detected_error_count(self.console.iter().map(String::as_str)) + } } impl IrisConnection { @@ -902,25 +909,9 @@ impl IrisConnection { .collect() }) .unwrap_or_default(); - let mut errors: Vec = vec![]; - if let Some(se) = body["status"]["errors"].as_array() { - for e in se { - if let Some(msg) = e["error"].as_str() { - errors.push(msg.to_string()); - } - } - } - // Issue #80: same colon-vs-space prefix defect as iris_compile's own console loop, - // a second consumer away (iris_doc{mode:put, compile:true}.compile_errors, and - // iris_compile's local-source upload path). Shares the one parser so the two cannot - // drift apart again. - for line in &console { - if let Some(d) = crate::tools::parse_console_diag(line, "ERROR:", "ERROR ") { - if errors.iter().all(|e| !e.contains(&d.text)) { - errors.push(d.text); - } - } - } + // Issue #80: the console loop lived here, in iris_compile and in iris_doc, and the + // fix reached two of the three. One shared assembly now, so they cannot drift. + let errors = crate::tools::compile_error_list(&body, &console); Ok(CompileResult { errors, console }) } diff --git a/crates/iris-agentic-dev-core/src/tools/doc.rs b/crates/iris-agentic-dev-core/src/tools/doc.rs index 6acf5df..c8012bf 100644 --- a/crates/iris-agentic-dev-core/src/tools/doc.rs +++ b/crates/iris-agentic-dev-core/src/tools/doc.rs @@ -859,22 +859,14 @@ async fn do_write( .collect() }) .unwrap_or_default(); - let mut errs: Vec = vec![]; - if let Some(se) = body["status"]["errors"].as_array() { - for e in se { - if let Some(msg) = e["error"].as_str() { - errs.push(msg.to_string()); - } - } - } - for line in &console { - if line.trim().starts_with("ERROR ") { - let msg = line.trim().to_string(); - if errs.iter().all(|e| !e.contains(line.trim())) { - errs.push(msg); - } - } - } + // #80 was fixed in iris_compile and in `compile_document` and never reached + // HERE, the third copy of the same loop. This build prefixes per-method + // diagnostics with `ERROR:` (colon); matching only `ERROR ` (space) missed + // every one of them. Measured live on IRIS 2026.1 (Build 235U): a 3-method + // class with 3 undefined macros printed `Detected 13 errors` and this path + // reported ONE — `#5123 Unable to find entry point`, a cascade, while the + // macros that caused it never appeared. + let errs = crate::tools::compile_error_list(&body, &console); (errs.is_empty(), errs, console) } }; @@ -895,6 +887,12 @@ async fn do_write( "compile_errors": compile_errors, "compile_console": compile_console, }); + crate::tools::note_error_undercount( + &mut payload, + crate::tools::detected_error_count(compile_console.iter().map(String::as_str)), + compile_errors.len(), + "compile_console", + ); note_compile_time_methods(&mut payload, &generators); return crate::tools::envelope::fail_with("COMPILE_ERROR", &first, payload); } @@ -907,6 +905,14 @@ async fn do_write( "compile_errors": compile_errors, "compile_console": compile_console, }); + // Reached with compile_errors EMPTY. If IRIS still counted errors here, "compiled: + // true" is the undercount at its worst — a failed compile reported as a success. + crate::tools::note_error_undercount( + &mut payload, + crate::tools::detected_error_count(compile_console.iter().map(String::as_str)), + compile_errors.len(), + "compile_console", + ); note_compile_time_methods(&mut payload, &generators); return ok_json(payload); } diff --git a/crates/iris-agentic-dev-core/src/tools/mod.rs b/crates/iris-agentic-dev-core/src/tools/mod.rs index 78b0016..694cbc6 100644 --- a/crates/iris-agentic-dev-core/src/tools/mod.rs +++ b/crates/iris-agentic-dev-core/src/tools/mod.rs @@ -4382,7 +4382,7 @@ impl IrisTools { } #[tool( - description = "Compile an ObjectScript class, routine, or wildcard package on IRIS via Atelier REST. Wildcards ('MyApp.*', 'MyApp.*.cls') expand CLASS documents only and are guarded: the pattern MUST begin with a literal package prefix before its first '*' (bare '*', '*.cls', '*Foo' are refused as SCOPE_REQUIRED — they would select the whole namespace), and a pattern matching more than 500 documents is refused as TOO_BROAD with the count, so narrow the package rather than retrying. Matching ignores the document suffix, so 'Pkg.Class.*' means the SUBPACKAGE of Pkg.Class, never Pkg.Class itself; a pattern that matches nothing is NOT_FOUND. Atelier's listing omits Hidden and generated classes, so a wildcard cannot expand them: any that match are named in `not_expanded` with a count, and are NOT compiled — compile those by exact name. Compile a .mac/.int/.inc routine by its exact name. Returns structured errors with line numbers, columns, and severity. No Python required." + description = "Compile an ObjectScript class, routine, or wildcard package on IRIS via Atelier REST. Wildcards ('MyApp.*', 'MyApp.*.cls') expand CLASS documents only and are guarded: the pattern MUST begin with a literal package prefix before its first '*' (bare '*', '*.cls', '*Foo' are refused as SCOPE_REQUIRED — they would select the whole namespace), and a pattern matching more than 500 documents is refused as TOO_BROAD with the count, so narrow the package rather than retrying. Matching ignores the document suffix, so 'Pkg.Class.*' means the SUBPACKAGE of Pkg.Class, never Pkg.Class itself; a pattern that matches nothing is NOT_FOUND. Atelier's listing omits Hidden and generated classes, so a wildcard cannot expand them: any that match are named in `not_expanded` with a count, and are NOT compiled — compile those by exact name. Compile a .mac/.int/.inc routine by its exact name. Returns structured errors with line numbers, columns, and severity. The list is cross-checked against IRIS's own `Detected N errors` tally: if `errors_incomplete: true` appears (with `errors_detected_by_iris` / `errors_reported`), the errors array is a SUBSET and its first entry is NOT necessarily the root cause — read `console` in full before deciding what to fix. No Python required." )] async fn iris_compile( &self, @@ -4495,7 +4495,7 @@ impl IrisTools { .collect(); let success = cr.success(); self.record_call("iris_compile", success); - let payload = serde_json::json!({ + let mut payload = serde_json::json!({ "success": success, "target": doc_name, "uploaded_from": local_src, @@ -4505,6 +4505,12 @@ impl IrisTools { "warnings": [], "console": console, }); + note_error_undercount( + &mut payload, + cr.detected_error_count(), + cr.errors.len(), + "console", + ); if !success { return compile_failure(&doc_name, payload); } @@ -4821,6 +4827,11 @@ impl IrisTools { let success = errors.is_empty(); self.record_call("iris_compile", success); + // The list above is only as complete as the console parser. IRIS counts its own + // errors; compare, so a parser that has fallen behind cannot hand back a short list + // that reads like a complete one. + let detected = detected_error_count(console.iter().filter_map(|v| v.as_str())); + let reported = errors.len(); // Write open hint for single non-wildcard successful compile let open_uri = if success && !p.target.contains('*') && targets.len() == 1 { @@ -4839,6 +4850,7 @@ impl IrisTools { "warnings": warnings, "console": console, }); + note_error_undercount(&mut resp, detected, reported, "console"); if let Some(uri) = open_uri { resp["open_uri"] = serde_json::Value::String(uri); } @@ -5750,7 +5762,7 @@ do ##class(%UnitTest.Manager).RunTest({pattern},"{flags}","{token}")"#, } #[tool( - description = "Read, write, delete, or check an IRIS document. mode='get' fetches source, mode='put' writes (with automatic SCM checkout if needed), mode='delete' removes, mode='head' checks existence. name needs the Atelier type suffix — 'MyApp.Patient.cls', not 'MyApp.Patient' (put adds it for you when the content starts with `Class ` or `ROUTINE `). Supports batch ops via 'names' array and elicitation_id/elicitation_answer for SCM dialog resumption. For large source, paginate get with max_bytes + offset (response includes next_offset), or prefer docs_introspect for signatures/structure instead of full source. No Python required." + description = "Read, write, delete, or check an IRIS document. mode='get' fetches source, mode='put' writes (with automatic SCM checkout if needed), mode='delete' removes, mode='head' checks existence. name needs the Atelier type suffix — 'MyApp.Patient.cls', not 'MyApp.Patient' (put adds it for you when the content starts with `Class ` or `ROUTINE `). Supports batch ops via 'names' array and elicitation_id/elicitation_answer for SCM dialog resumption. For large source, paginate get with max_bytes + offset (response includes next_offset), or prefer docs_introspect for signatures/structure instead of full source. With compile=true, compile_errors is cross-checked against IRIS's own `Detected N errors` tally — `errors_incomplete: true` means the list is a SUBSET and `compile_console` holds the rest. No Python required." )] async fn iris_doc( &self, @@ -9297,11 +9309,21 @@ fn console_diag_already_reported(errors: &[serde_json::Value], text: &str) -> bo errors.iter().any(|e| { e["text"] .as_str() - .map(|t| !t.contains('\n') && t.contains(text)) - .unwrap_or(false) + .is_some_and(|t| diag_stands_in_for(t, text)) }) } +/// The rule behind [`console_diag_already_reported`], over a plain string list so the +/// `Vec` consumers (`compile_document`, `iris_doc{mode:put, compile:true}`) apply the +/// SAME test instead of each copying `errors.iter().all(|e| !e.contains(text))`. +/// +/// #80: a single-line `status.errors` entry that repeats a console line stands in for it. A +/// MULTI-LINE wrapper does not — it embeds the first per-method message inside itself, so a +/// plain `contains` swallows that one method's entry and N broken methods report N-1. +pub fn diag_stands_in_for(existing: &str, text: &str) -> bool { + !existing.contains('\n') && existing.contains(text) +} + /// A console line that is only a document name. IRIS repeats `ERROR: Foo.Bar.cls` once per /// routine error as a header; it carries no diagnostic of its own. fn is_bare_docname(s: &str) -> bool { @@ -9400,6 +9422,107 @@ pub(crate) fn parse_console_diag( }) } +/// Assemble a compile's error list from an Atelier response: the `status.errors` entries +/// first, then every console diagnostic the shared parser recognises that no single-line +/// status entry already stands in for. +/// +/// Extracted because this loop existed THREE times — `iris_compile`, `compile_document` and +/// `iris_doc{mode:put, compile:true}` — and #80 was fixed in two of them. The third kept +/// matching `ERROR ` (space) and, measured live on IRIS 2026.1 (Build 235U), reported ONE of +/// the thirteen errors IRIS counted. A copy cannot fall behind if there is no copy. +/// +/// `iris_compile` keeps its own richer assembly (it emits code/line/location per entry and +/// has a `status.summary` fallback); these two return plain strings. +pub fn compile_error_list(body: &serde_json::Value, console: &[String]) -> Vec { + let mut errors: Vec = vec![]; + if let Some(se) = body["status"]["errors"].as_array() { + for e in se { + if let Some(msg) = e["error"].as_str() { + errors.push(msg.to_string()); + } + } + } + for line in console { + if let Some(d) = parse_console_diag(line, "ERROR:", "ERROR ") { + if !errors.iter().any(|e| diag_stands_in_for(e, &d.text)) { + errors.push(d.text); + } + } + } + errors +} + +/// IRIS's own error arithmetic, read off the line it prints at the end of every compile: +/// `Detected 13 errors during compilation in 0.048s.` +/// +/// The compiler counts those itself — the number does not pass through our console parser — +/// so it is the one value that can tell a caller our parsed list is SHORT. That matters +/// because a short list reads exactly like a complete one. #80 fixed the parser on one path +/// and left `iris_doc{mode:put, compile:true}` matching only `ERROR ` (space); measured live +/// on IRIS 2026.1 (Build 235U), a 3-method class with 3 undefined macros made IRIS print +/// `Detected 13 errors` while that path reported ONE — and the one it reported (`#5123 +/// Unable to find entry point for method 'M1'`) was a cascade, not the cause. +/// +/// The invariant is `reported >= detected`, NOT equality: the `status.errors` wrapper is an +/// extra entry IRIS does not count (the same class gave `iris_compile` 14 against 13). +/// +/// With several such lines the LARGEST wins, not the sum. A multi-target compile is not +/// measured here to emit only per-item lines; if IRIS also prints a grand total, summing +/// would double-count and flag a complete list as short. A max can only understate the true +/// total, so it never manufactures an undercount. +pub fn detected_error_count<'a>(console: impl IntoIterator) -> Option { + console + .into_iter() + .filter_map(|line| { + let rest = line.trim().strip_prefix("Detected ")?; + let (n, tail) = rest.split_once(' ')?; + // "errors" on the measured build; the singular is accepted in case one prints it. + if !tail.starts_with("error") { + return None; + } + n.parse::().ok() + }) + .max() +} + +/// Record on a compile response that our error list is shorter than IRIS's own count. +/// +/// Also REPLACES the hint: "fix the first reported error, later ones are cascades of it" is +/// actively wrong when the list is short — on the measured run the only surviving entry WAS +/// a cascade and the root cause was absent. `merge_hint` and the envelope's built-in +/// COMPILE_ERROR hint both yield to a hint the payload already carries, so this one wins on +/// every path. +/// +/// `console_field` names the field on THIS payload holding the raw compiler output: +/// `console` for iris_compile, `compile_console` for iris_doc. +pub fn note_error_undercount( + payload: &mut serde_json::Value, + detected: Option, + reported: usize, + console_field: &str, +) { + let Some(detected) = detected.filter(|d| *d > reported) else { + return; + }; + let Some(obj) = payload.as_object_mut() else { + return; + }; + obj.insert("errors_incomplete".into(), serde_json::json!(true)); + obj.insert( + "errors_detected_by_iris".into(), + serde_json::json!(detected), + ); + obj.insert("errors_reported".into(), serde_json::json!(reported)); + obj.insert( + "hint".into(), + serde_json::json!(format!( + "INCOMPLETE: IRIS counted {detected} errors and only {reported} were recovered \ + from the compiler console. Do NOT treat the first entry as the root cause — read \ + `{console_field}`, which holds every line IRIS printed." + )), + ); +} + #[cfg(test)] mod tests { use super::*; @@ -12993,6 +13116,219 @@ mod console_diag_tests { .iter() .all(|e| !e["location"].as_str().unwrap_or("").is_empty())); } + + // ── Detected-N cross-check ──────────────────────────────────────────────── + + /// The compiler console of the live measurement, verbatim: IRIS 2026.1 (Build 235U), + /// namespace APP, class `Zz.MSG.Undercount` — three methods, three undefined macros. + /// IRIS printed `Detected 13 errors`; `iris_compile` returned 14 entries (13 + the + /// status.errors wrapper) and `iris_doc{mode:put, compile:true}` returned ONE. + fn measured_console() -> Vec<&'static str> { + vec![ + "", + "Compilation started on 09/07/2026 09:41:30 with qualifiers 'cuk'", + "Compiling class Zz.MSG.Undercount", + "Compiling table Zz_MSG.Undercount", + "Compiling routine Zz.MSG.Undercount.1", + "ERROR: Zz.MSG.Undercount.cls", + "ERROR: Zz.MSG.Undercount.1(3) : MPP5610 : Referenced macro not defined: 'ZzUndefined0'", + " TEXT: set x = $$$ZzUndefined0 }", + "ERROR: Zz.MSG.Undercount.cls", + "ERROR: Zz.MSG.Undercount.1(3) : MPP5610 : Referenced macro not defined: 'ZzUndefined1'", + " TEXT: set x = $$$ZzUndefined1 }", + "ERROR: Zz.MSG.Undercount.cls", + "ERROR: Zz.MSG.Undercount.1(3) : MPP5610 : Referenced macro not defined: 'ZzUndefined2'", + " TEXT: set x = $$$ZzUndefined2 }", + "ERROR: Zz.MSG.Undercount.cls(M0+2) #1002: Invalid character in tag : '$$$ZzUndefined0' : Offset:15 [M0+1^Zz.MSG.Undercount.1]", + " TEXT: set x = $$$ZzUndefined0 }", + "ERROR: Zz.MSG.Undercount.cls(M1) #1026: Invalid command : 'methodimpl {' : Offset:16 [M1^Zz.MSG.Undercount.1]", + " TEXT: M1() methodimpl {", + "ERROR: Zz.MSG.Undercount.cls(M1+2) #1002: Invalid character in tag : '$$$ZzUndefined1' : Offset:15 [M1+1^Zz.MSG.Undercount.1]", + " TEXT: set x = $$$ZzUndefined1 }", + "ERROR: Zz.MSG.Undercount.cls(M2) #1026: Invalid command : 'methodimpl {' : Offset:16 [M2^Zz.MSG.Undercount.1]", + " TEXT: M2() methodimpl {", + "ERROR: Zz.MSG.Undercount.cls(M2+2) #1002: Invalid character in tag : '$$$ZzUndefined2' : Offset:15 [M2+1^Zz.MSG.Undercount.1]", + " TEXT: set x = $$$ZzUndefined2 }", + "ERROR: Zz.MSG.Undercount.cls(XMLExportInternal+2) #1038: Private variable not allowed : 'tag,summary,attrsVal,savelocal,aval,k,tmpPrefix,prefixDepth,hasNoContent,hasElement,topAttrs,beginprefix,endprefix,savexsiAttrs,initialxsiAttrs,initlist,initialCR,inlineFlag,popAtEnd,saveTopPrefix,saveTypesPrefix,saveAttrsPrefix,saveUsePrefix,initlist' : Offset:8 [zXMLExportInternal+1^Zz.MSG.Undercount.1]", + "ERROR: Zz.MSG.Undercount.cls(XMLImportInternal+3) #1038: Private variable not allowed : 'child,childlist,node,nodelist,inner,innerlist,data,encodedArray,key,nsIndex,savechild,savechildlist,saveinner,saveinnnerlist,exists' : Offset:10 [zXMLImportInternal+1^Zz.MSG.Undercount.1]", + "ERROR: Zz.MSG.Undercount.cls(XMLImportInternal+48) #1038: Private variable not allowed : 'msg,loc' : Offset:31 [XMLImportLocation^Zz.MSG.Undercount.1]", + " TEXT: XMLImportLocation(node) new msg,loc", + "ERROR: Zz.MSG.Undercount.1.int(614) #1026: Invalid command : 'ExtentExecute(%qHandle) [' : Offset:24 [ExtentExecute^Zz.MSG.Undercount.1]", + "ERROR #5123: Unable to find entry point for method 'M1' in routine 'Zz.MSG.Undercount.1'", + "Detected 13 errors during compilation in 0.048s.", + ] + } + + #[test] + fn detected_error_count_reads_the_measured_line() { + assert_eq!(detected_error_count(measured_console()), Some(13)); + } + + #[test] + fn detected_error_count_is_none_when_iris_printed_no_tally() { + assert_eq!( + detected_error_count(vec![ + "Compilation started on 09/07/2026 09:41:30 with qualifiers 'cuk'", + "Compiling class Zz.MSG.Undercount", + "Compilation finished successfully in 0.003s.", + ]), + None + ); + } + + /// Negative controls on each half of the match, so the parser cannot pass by being + /// permissive — a wrong count here would flag healthy compiles as incomplete. + #[test] + fn detected_error_count_ignores_lines_that_are_not_an_error_tally() { + for line in [ + "Detected 4 warnings during compilation in 0.010s.", + "Detected changes in 2 classes", + "Detected errors during compilation", + "Compilation Detected 9 errors during compilation", + ] { + assert_eq!(detected_error_count(vec![line]), None, "{line}"); + } + } + + /// Several tallies take the LARGEST, never the sum: an IRIS that printed per-item lines + /// AND a grand total would make a sum double-count and condemn a complete list. + #[test] + fn detected_error_count_takes_the_largest_not_the_sum() { + assert_eq!( + detected_error_count(vec![ + "Detected 3 errors during compilation in 0.001s.", + "Detected 5 errors during compilation in 0.002s.", + ]), + Some(5) + ); + } + + /// The distinguishing test. The same measured console goes through both console loops: + /// the shared parser recovers all 13 and is NOT flagged, the `ERROR ` (space) prefix + /// `iris_doc` used until now recovers 1 and IS. If doc.rs ever regresses to + /// `starts_with("ERROR ")`, the undercount half of this stops being hypothetical. + #[test] + fn the_cross_check_separates_a_complete_list_from_the_measured_undercount() { + let console = measured_console(); + let detected = detected_error_count(console.iter().copied()); + assert_eq!(detected, Some(13)); + + // What the space-only loop actually recovered, live: one entry — and #5123 is a + // CASCADE of the undefined macros, which never appear at all. + let old: Vec = console + .iter() + .filter(|l| l.trim().starts_with("ERROR ")) + .map(|l| l.trim().to_string()) + .collect(); + assert_eq!(old.len(), 1, "the measured undercount: {old:#?}"); + assert!(old[0].contains("#5123"), "{old:#?}"); + + let mut short = serde_json::json!({"compiled": false, "compile_errors": old.clone()}); + note_error_undercount(&mut short, detected, old.len(), "compile_console"); + assert_eq!(short["errors_incomplete"], serde_json::json!(true)); + assert_eq!(short["errors_detected_by_iris"], serde_json::json!(13)); + assert_eq!(short["errors_reported"], serde_json::json!(1)); + let hint = short["hint"].as_str().unwrap_or_default(); + assert!( + hint.contains("INCOMPLETE") && hint.contains("compile_console"), + "the hint must name the field holding the rest: {hint}" + ); + + // The PRODUCTION assembly on the SAME lines — the function iris_doc and + // compile_document both call now, not a copy of it here: 13 recovered, none flagged. + let owned: Vec = console.iter().map(|l| l.to_string()).collect(); + let fixed = compile_error_list(&serde_json::json!({}), &owned); + assert_eq!( + fixed.len(), + 13, + "every error IRIS counted must be recovered: {fixed:#?}" + ); + for macro_name in ["ZzUndefined0", "ZzUndefined1", "ZzUndefined2"] { + assert!( + fixed.iter().any(|e| e.contains(macro_name)), + "{macro_name} caused the compile to fail and must be reachable: {fixed:#?}" + ); + } + let mut ok = serde_json::json!({"compiled": false, "compile_errors": fixed}); + note_error_undercount(&mut ok, detected, 13, "compile_console"); + assert!( + ok.get("errors_incomplete").is_none(), + "a complete list must not be flagged: {ok:#?}" + ); + } + + /// The wrapper path through the SHARED assembly: `iris_compile`'s response carried a + /// multi-line `status.errors` entry that embeds the first per-method message. It must + /// add an entry, not swallow the method it quotes — 14, and all three macros still + /// individually reachable. + #[test] + fn a_multi_line_status_wrapper_adds_an_entry_without_swallowing_one() { + let console: Vec = measured_console().iter().map(|l| l.to_string()).collect(); + let body = serde_json::json!({"status": {"errors": [{"error": + "ERROR #5475: Error compiling routine: Zz.MSG.Undercount. Errors: \ + Zz.MSG.Undercount.cls\r\nERROR: Zz.MSG.Undercount.1(3) : MPP5610 : Referenced \ + macro not defined: 'ZzUndefined0'"}]}}); + let errors = compile_error_list(&body, &console); + assert_eq!( + errors.len(), + 14, + "1 wrapper + 13 console errors: {errors:#?}" + ); + for macro_name in ["ZzUndefined0", "ZzUndefined1", "ZzUndefined2"] { + assert!( + errors + .iter() + .filter(|e| !e.contains('\n')) + .any(|e| e.contains(macro_name)), + "{macro_name} has no single-line entry of its own: {errors:#?}" + ); + } + // 14 against IRIS's 13 — one MORE than detected, and still silent. + let mut payload = serde_json::json!({"success": false}); + note_error_undercount( + &mut payload, + detected_error_count(console.iter().map(String::as_str)), + errors.len(), + "console", + ); + assert!(payload.get("errors_incomplete").is_none(), "{payload:#?}"); + } + + /// The positive control for the "equality is the wrong invariant" claim: `iris_compile` + /// carries the status.errors wrapper IRIS does not count, so it reports 14 against 13 — + /// one MORE than detected — and that must stay silent. + #[test] + fn a_status_errors_wrapper_over_the_detected_count_is_not_an_undercount() { + let mut payload = serde_json::json!({"success": false}); + note_error_undercount(&mut payload, Some(13), 14, "console"); + assert!(payload.get("errors_incomplete").is_none(), "{payload:#?}"); + // …and an unparseable console leaves the response exactly as it was. + note_error_undercount(&mut payload, None, 0, "console"); + assert_eq!(payload, serde_json::json!({"success": false})); + } + + /// The undercount hint must WIN over the generic COMPILE_ERROR hint, which tells the + /// caller to fix the first error — the one piece of advice that is wrong when the list + /// is short, since on the measured run the only entry left was a cascade. + #[test] + fn the_undercount_hint_survives_the_compile_error_envelope() { + let mut payload = serde_json::json!({"errors": []}); + note_error_undercount(&mut payload, Some(13), 1, "console"); + let result = crate::tools::envelope::fail_with("COMPILE_ERROR", "boom", payload); + let text = match &result.expect("envelope").content[0].raw { + rmcp::model::RawContent::Text(t) => t.text.clone(), + other => panic!("unexpected content: {other:?}"), + }; + let v: serde_json::Value = serde_json::from_str(&text).expect("json"); + assert_eq!(v["errors_incomplete"], serde_json::json!(true)); + assert!( + v["hint"] + .as_str() + .unwrap_or_default() + .contains("INCOMPLETE"), + "the built-in hint must not win: {v:#?}" + ); + } } // ── Issue #78: iris_get_log addressing ────────────────────────────────────────