// tools/hako_check/cli.hako - HakoAnalyzerBox (MVP) using selfhost.shared.common.string_helpers as Str using tools.hako_check.analysis_consumer as HakoAnalysisBuilderBox using tools.hako_check.rules.rule_include_forbidden as RuleIncludeForbiddenBox using tools.hako_check.rules.rule_using_quoted as RuleUsingQuotedBox using tools.hako_check.rules.rule_static_top_assign as RuleStaticTopAssignBox using tools.hako_check.rules.rule_global_assign as RuleGlobalAssignBox using tools.hako_check.rules.rule_dead_methods as RuleDeadMethodsBox using tools.hako_check.rules.rule_jsonfrag_usage as RuleJsonfragUsageBox using tools.hako_check.rules.rule_unused_alias as RuleUnusedAliasBox using tools.hako_check.rules.rule_non_ascii_quotes as RuleNonAsciiQuotesBox using tools.hako_check.rules.rule_dead_static_box as RuleDeadStaticBoxBox using tools.hako_check.rules.rule_duplicate_method as RuleDuplicateMethodBox using tools.hako_check.rules.rule_missing_entrypoint as RuleMissingEntrypointBox using tools.hako_check.rules.rule_top_level_local as RuleTopLevelLocalBox using tools.hako_check.rules.rule_arity_mismatch as RuleArityMismatchBox using tools.hako_check.rules.rule_stage3_gate as RuleStage3GateBox using tools.hako_check.rules.rule_brace_heuristics as RuleBraceHeuristicsBox using tools.hako_check.rules.rule_analyzer_io_safety as RuleAnalyzerIoSafetyBox using tools.hako_check.rules.rule_dead_code as DeadCodeAnalyzerBox using tools.hako_check.rules.rule_dead_blocks as DeadBlockAnalyzerBox using tools.hako_check.render.graphviz as GraphvizRenderBox using tools.hako_parser.parser_core as HakoParserCoreBox static box HakoAnalyzerBox { run(args) { if args == null || args.size() < 1 { print("[lint/error] missing paths"); return 2 } // options: --format {text|dot|json} (accept anywhere) local fmt = "text" local debug = 0 // Default: AST path OFF (enable with --force-ast) local no_ast = 1 // QuickFix options (Phase 21.8) local fix_dry_run = 0 local fix_scope = "text" // reserved for future local fix_plan = 0 // AST rename options local ren_boxes = new ArrayBox() // Array local ren_methods = new ArrayBox() // Array // single-pass parse: handle options in-place and collect sources local i = 0 local fail = 0 local irs = new ArrayBox() local diags = new ArrayBox() // optional filters local rules_only = null // ArrayBox of keys local rules_skip = null // ArrayBox of keys local dead_code_mode = 0 // Phase 153: --dead-code flag local dead_blocks_mode = 0 // Phase 154: --dead-blocks flag local mir_json_content = null // Phase 156: MIR JSON content for CFG integration // Support inline sources: --source-file . Also accept --debug and --format anywhere. while i < args.size() { local p = args.get(i) // handle options if p == "--debug" { debug = 1; i = i + 1; continue } if p == "--dead-code" { dead_code_mode = 1; i = i + 1; continue } if p == "--dead-blocks" { dead_blocks_mode = 1; i = i + 1; continue } if p == "--mir-json-content" { if i + 1 >= args.size() { print("[lint/error] --mir-json-content requires content"); return 2 } mir_json_content = args.get(i+1); i = i + 2; continue } if p == "--no-ast" { no_ast = 1; i = i + 1; continue } if p == "--force-ast" { no_ast = 0; i = i + 1; continue } if p == "--format" { if i + 1 >= args.size() { print("[lint/error] --format requires value"); return 2 } fmt = args.get(i+1); i = i + 2; continue } if p == "--fix-dry-run" { fix_dry_run = 1; i = i + 1; continue } if p == "--fix-scope" { if i + 1 >= args.size() { print("[lint/error] --fix-scope requires value"); return 2 } fix_scope = args.get(i+1); i = i + 2; continue } if p == "--fix-plan" { fix_plan = 1; i = i + 1; continue } if p == "--rename-box" { if i + 2 >= args.size() { print("[lint/error] --rename-box requires "); return 2 } local rec = new MapBox(); rec.set("old", args.get(i+1)); rec.set("new", args.get(i+2)); ren_boxes.push(rec); i = i + 3; continue } if p == "--rename-method" { if i + 3 >= args.size() { print("[lint/error] --rename-method requires "); return 2 } local recm = new MapBox(); recm.set("box", args.get(i+1)); recm.set("old", args.get(i+2)); recm.set("new", args.get(i+3)); ren_methods.push(recm); i = i + 4; continue } if p == "--rules" { if i + 1 >= args.size() { print("[lint/error] --rules requires CSV"); return 2 } rules_only = me._parse_csv(args.get(i+1)); i = i + 2; continue } if p == "--skip-rules" { if i + 1 >= args.size() { print("[lint/error] --skip-rules requires CSV"); return 2 } rules_skip = me._parse_csv(args.get(i+1)); i = i + 2; continue } // source handling local text = null if p == "--source-file" { if i + 2 < args.size() { p = args.get(i+1); text = args.get(i+2); i = i + 3 } else { print("[lint/error] --source-file requires "); return 2 } } else { // Read from filesystem via FileBox (plugin must be available) local f = new FileBox(); if f.open(p) == 0 { print("[lint/error] cannot open: " + p); fail = fail + 1; i = i + 1; continue } text = f.read(); f.close(); i = i + 1 } // keep a copy before sanitize for rules that must see original bytes (HC017, etc.) local text_raw = text // pre-sanitize (ASCII quotes, normalize newlines) - minimal & reversible text = me._sanitize(text) // Phase 21.8: QuickFix (--fix-dry-run) if fix_dry_run == 1 && (ren_boxes.size() == 0 && ren_methods.size() == 0) { // Apply text-scope quick fixes in a safe subset and print unified diff local patched = me._fix_text_quick(text) if patched != null && patched != text { me._print_unified_diff(p, text, patched) } // In dry-run mode, we do not run analyzers further continue } // AST-scope rename (dry-run or plan) if (ren_boxes.size() > 0 || ren_methods.size() > 0) { if fix_plan == 1 { me._print_refactor_plan_json(p, ren_boxes, ren_methods) continue } if fix_dry_run == 1 { local patched_ast = me._fix_ast_rename(text, p, ren_boxes, ren_methods) if patched_ast != null && patched_ast != text { me._print_unified_diff(p, text, patched_ast) } continue } } // analysis - only build IR if needed by active rules local ir = null if me._needs_ir(rules_only, rules_skip) == 1 { // Enable AST inside IR builder only when the active rules explicitly need it local want_ast = me._needs_ast(rules_only, rules_skip) local no_ast_eff = no_ast if want_ast == 1 { no_ast_eff = 0 } ir = HakoAnalysisBuilderBox.build_from_source_flags(text, p, no_ast_eff) // Fail-Fast: Guard against void return from builder if ir == null || ir == void { ir = new MapBox() ir.set("path", p) ir.set("methods", new ArrayBox()) ir.set("calls", new ArrayBox()) ir.set("boxes", new ArrayBox()) ir.set("entrypoints", new ArrayBox()) } // Phase 156: Integrate MIR CFG if available if mir_json_content != null { ir.set("_mir_json_text", mir_json_content) } } else { // Minimal IR stub for rules that don't need it ir = new MapBox() ir.set("path", p) ir.set("methods", new ArrayBox()) ir.set("calls", new ArrayBox()) ir.set("boxes", new ArrayBox()) ir.set("entrypoints", new ArrayBox()) } // parse AST only when explicitly needed by active rules (include_forbidden fallback allowed) local ast = null if no_ast == 0 && me._needs_ast(rules_only, rules_skip) == 1 { ast = HakoParserCoreBox.parse(text) } if debug == 1 { local mc = (ir.get("methods")!=null)?ir.get("methods").size():0 local cc = (ir.get("calls")!=null)?ir.get("calls").size():0 local ec = (ir.get("entrypoints")!=null)?ir.get("entrypoints").size():0 print("[hako_check/IR] file=" + p + " methods=" + me._itoa(mc) + " calls=" + me._itoa(cc) + " eps=" + me._itoa(ec)) } irs.push(ir) // rules that work on raw source local out = new ArrayBox() if ast != null { local before = out.size() if me._rule_enabled(rules_only, rules_skip, "include_forbidden") == 1 { RuleIncludeForbiddenBox.apply_ast(ast, p, out) } // Fallback to text scan if AST did not detect any include if out.size() == before && me._rule_enabled(rules_only, rules_skip, "include_forbidden") == 1 { RuleIncludeForbiddenBox.apply(text, p, out) } } else { if me._rule_enabled(rules_only, rules_skip, "include_forbidden") == 1 { RuleIncludeForbiddenBox.apply(text, p, out) } } if me._rule_enabled(rules_only, rules_skip, "using_quoted") == 1 { RuleUsingQuotedBox.apply(text, p, out) } if me._rule_enabled(rules_only, rules_skip, "unused_alias") == 1 { RuleUnusedAliasBox.apply(text, p, out) } if me._rule_enabled(rules_only, rules_skip, "static_top_assign") == 1 { RuleStaticTopAssignBox.apply(text, p, out) } if me._rule_enabled(rules_only, rules_skip, "global_assign") == 1 { RuleGlobalAssignBox.apply(text, p, out) } // HC017 must inspect original text prior to sanitize if me._rule_enabled(rules_only, rules_skip, "non_ascii_quotes") == 1 { RuleNonAsciiQuotesBox.apply(text_raw, p, out) } if me._rule_enabled(rules_only, rules_skip, "jsonfrag_usage") == 1 { RuleJsonfragUsageBox.apply(text, p, out) } if me._rule_enabled(rules_only, rules_skip, "top_level_local") == 1 { RuleTopLevelLocalBox.apply(text, p, out) } if me._rule_enabled(rules_only, rules_skip, "stage3_gate") == 1 { RuleStage3GateBox.apply(text, p, out) } // HC031 must inspect original text prior to sanitize (like HC017) local before_hc031 = out.size() if me._rule_enabled(rules_only, rules_skip, "brace_heuristics") == 1 { RuleBraceHeuristicsBox.apply(text_raw, p, out) } if debug == 1 { local added_hc031 = out.size() - before_hc031 print("[hako_check/HC031] file=" + p + " added=" + me._itoa(added_hc031) + " total_out=" + me._itoa(out.size())) } if me._rule_enabled(rules_only, rules_skip, "analyzer_io_safety") == 1 { RuleAnalyzerIoSafetyBox.apply(text, p, out) } // rules that need IR (enable dead code detection) local before_n = out.size() if me._rule_enabled(rules_only, rules_skip, "dead_methods") == 1 { me._log_stderr("[rule/exec] HC011 (dead_methods) " + p) RuleDeadMethodsBox.apply_ir(ir, p, out) } if debug == 1 { local after_n = out.size() local added = after_n - before_n print("[hako_check/HC011] file=" + p + " added=" + me._itoa(added) + " total_out=" + me._itoa(after_n)) } before_n = out.size() if me._rule_enabled(rules_only, rules_skip, "dead_static_box") == 1 { me._log_stderr("[rule/exec] HC012 (dead_static_box) " + p) RuleDeadStaticBoxBox.apply_ir(ir, p, out) } if debug == 1 { local after_n = out.size() local added = after_n - before_n local boxes_count = (ir.get("boxes")!=null)?ir.get("boxes").size():0 print("[hako_check/HC012] file=" + p + " boxes=" + me._itoa(boxes_count) + " added=" + me._itoa(added) + " total_out=" + me._itoa(after_n)) } before_n = out.size() if me._rule_enabled(rules_only, rules_skip, "duplicate_method") == 1 { me._log_stderr("[rule/exec] HC013 (duplicate_method) " + p) RuleDuplicateMethodBox.apply_ir(ir, p, out) } if debug == 1 { local after_n = out.size() local added = after_n - before_n print("[hako_check/HC013] file=" + p + " added=" + me._itoa(added) + " total_out=" + me._itoa(after_n)) } before_n = out.size() if me._rule_enabled(rules_only, rules_skip, "missing_entrypoint") == 1 { me._log_stderr("[rule/exec] HC014 (missing_entrypoint) " + p) RuleMissingEntrypointBox.apply_ir(ir, p, out) } if debug == 1 { local after_n = out.size() local added = after_n - before_n print("[hako_check/HC014] file=" + p + " added=" + me._itoa(added) + " total_out=" + me._itoa(after_n)) } before_n = out.size() if me._rule_enabled(rules_only, rules_skip, "arity_mismatch") == 1 { me._log_stderr("[rule/exec] HC015 (arity_mismatch) " + p) RuleArityMismatchBox.apply_ir(ir, p, out) } if debug == 1 { local after_n = out.size() local added = after_n - before_n print("[hako_check/HC015] file=" + p + " added=" + me._itoa(added) + " total_out=" + me._itoa(after_n)) } // Phase 153: HC019 Dead Code Analyzer (comprehensive) before_n = out.size() if dead_code_mode == 1 || me._rule_enabled(rules_only, rules_skip, "dead_code") == 1 { me._log_stderr("[rule/exec] HC019 (dead_code) " + p) DeadCodeAnalyzerBox.apply_ir(ir, p, out) } if debug == 1 { local after_n = out.size() local added = after_n - before_n print("[hako_check/HC019] file=" + p + " added=" + me._itoa(added) + " total_out=" + me._itoa(after_n)) } // Phase 154: HC020 Dead Block Analyzer (block-level unreachable detection) before_n = out.size() if dead_blocks_mode == 1 || me._rule_enabled(rules_only, rules_skip, "dead_blocks") == 1 { me._log_stderr("[rule/exec] HC020 (dead_blocks) " + p) DeadBlockAnalyzerBox.apply_ir(ir, p, out) } if debug == 1 { local after_n = out.size() local added = after_n - before_n print("[hako_check/HC020] file=" + p + " added=" + me._itoa(added) + " total_out=" + me._itoa(after_n)) } // suppression: HC012(dead box) > HC011(unreachable method) local filtered = me._suppress_overlap(out) // flush (text only) local n = filtered.size(); if n > 0 && fmt == "text" { local j = 0; while j < n { print(filtered.get(j)); j = j + 1 } } // collect diagnostics for json-lsp local j2 = 0; while j2 < n { local msg = filtered.get(j2); local d = me._parse_msg_to_diag(msg, p); if d != null { diags.push(d) }; j2 = j2 + 1 } fail = fail + n } // optional DOT/JSON output if fmt == "dot" { me._render_dot_multi(irs) } if fmt == "json-lsp" { me._render_json_lsp(diags) } // return number of findings as RC return fail } // no-op _sanitize(text) { if text == null { return text } // Normalize CRLF -> LF local out = "" local n = text.length() local i2 = 0 while i2 < n { local ch = text.substring(i2, i2+1) // drop CR if ch == "\r" { i2 = i2 + 1; continue } // NOTE: Fancy quote conversion removed - StringBox lacks byte-level access out = out.concat(ch) i2 = i2 + 1 } return out } _render_json_lsp(diags) { // Emit diagnostics pretty-printed to match expected fixtures diags = me._sort_diags(diags) print("{\"diagnostics\":[") if diags != null { local i = 0 while i < diags.size() { local d = diags.get(i) local file = me._json_quote(d.get("file")) local line = me._itoa(d.get("line")) local rule = me._json_quote(d.get("rule")) local msg = me._json_quote(d.get("message")) local qf = d.get("quickFix"); if qf == null { qf = "" } local sev = d.get("severity"); if sev == null { sev = "warning" } local qfj = me._json_quote(qf) local entry = " {\"file\":" + file + ",\"line\":" + line + ",\"rule\":" + rule + ",\"message\":" + msg + ",\"quickFix\":" + qfj + ",\"severity\":\"" + sev + "\"}" if i != diags.size()-1 { print(entry + ",") } else { print(entry) } i = i + 1 } } print("]}") return 0 } // ---- QuickFix (text scope, MVP: HC003 using quoted) ---- _fix_text_quick(text) { if text == null { return null } // QuickFix (text scope): // - HC002 include → using // - HC003 using 非引用 → 引用化 // - HC016 未使用 alias の削除(using ... as Alias) // - HC014 missing entrypoint の空スタブ提案 local lines = me._split_lines(text) local changed = 0 local out = new ArrayBox() // 事前: alias 宣言の収集(行番号と別名) local alias_lines = new ArrayBox() // of Map {idx, alias} local i = 0 while i < lines.size() { local ln = lines.get(i) // HC002: include → using(相対→modulesの推測はしない;文字列そのまま移行) { local ltrim = me._ltrim(ln) if ltrim.indexOf("include \"") == 0 { // preserve indentation and trailing content (;等) local head_len = ln.indexOf("include \"") if head_len < 0 { head_len = 0 } local head = ln.substring(0, head_len) local rest = ln.substring(head_len) // rest begins with include " local after = rest.substring(8) // length("include ") = 8 local newline = head + "using " + after if newline != ln { changed = 1; out.push(newline) } else { out.push(ln) } i = i + 1 continue } } // HC016 用: using ... as Alias の抽出 { local ltrim2 = me._ltrim(ln) if ltrim2.indexOf("using ") == 0 && ltrim2.indexOf(" as ") > 0 { // パターン: using "X" as Alias もしくは using X as Alias local aspos = ltrim2.indexOf(" as ") local tail = ltrim2.substring(aspos + 4) // Alias の終端は空白/セミコロン/行末 local j = 0; local alias = "" while j < tail.length() { local ch = tail.substring(j,j+1) if ch == " " || ch == "\t" || ch == ";" { break } alias = alias + ch j = j + 1 } if alias != "" { local ent = new MapBox(); ent.set("idx", i); ent.set("alias", alias); alias_lines.push(ent) } } } // HC003: using 非引用 → 引用化 local ltrim = me._ltrim(ln) if ltrim.indexOf("using ") == 0 && ltrim.indexOf('using "') != 0 { // rewrite: using X ... -> using "X" ...(X は最初の空白/セミコロン/"as" まで) local prefix_len = ln.indexOf("using ") if prefix_len < 0 { prefix_len = 0 } local head = ln.substring(0, prefix_len) local rest = ln.substring(prefix_len) // rest starts with 'using ' local after = rest.substring(6) // Find boundary local b = 0; local n = after.length() while b < n { local ch = after.substring(b,b+1) // stop at space, semicolon or before ' as ' if ch == " " || ch == ";" { break } b = b + 1 } local name = after.substring(0,b) local tail = after.substring(b) // If tail starts with ' as ' but name had trailing spaces, we already stopped at space // Wrap name with quotes if not already if name.length() > 0 { local newline = head + "using \"" + name + "\"" + tail if newline != ln { changed = 1; out.push(newline) } else { out.push(ln) } } else { out.push(ln) } } else { out.push(ln) } i = i + 1 } // HC016: 未使用 alias の削除(安全に、完全一致のみ削除) if alias_lines.size() > 0 { // 再結合した out を基にテキストを作り、使用有無チェック local cur_text = ""; local k=0; while k= 0 { local ent = alias_lines.get(ai) local idx = ent.get("idx") local alias = ent.get("alias") if me._alias_unused(cur_text, alias) == 1 { // remove that line from out if matches using ... as alias // ただし out の該当行が using でない場合はスキップ if idx >= 0 && idx < out.size() { local l = me._ltrim(out.get(idx)) if l.indexOf("using ") == 0 && l.indexOf(" as ") > 0 && l.indexOf(" " + alias) > 0 { // delete out.set(idx, null) changed = 1 } } } ai = ai - 1 } // null を除去 local out2 = new ArrayBox(); local z=0; while z= 0 { return 0 } i = i + 1 } return 1 } _has_entrypoint(text) { if text == null { return 1 } // Rough check: presence of 'method main(' is considered as entrypoint if text.indexOf("method main(") >= 0 { return 1 } return 0 } _print_unified_diff(path, before, after) { if before == null || after == null || before == after { return 0 } // Minimal unified diff for whole file replacement(MVP) print("--- " + path) print("+++ " + path) print("@@") // Print lines with +/-; this MVP prints only changed lines as + and original as - when they differ line-wise local bl = me._split_lines(before); local al = me._split_lines(after) local max = bl.size(); if al.size() > max { max = al.size() } local i = 0 while i < max { local b = i < bl.size() ? bl.get(i) : null local a = i < al.size() ? al.get(i) : null if b == a { print(" " + (a==null?"":a)) } else { if b != null { print("-" + b) } if a != null { print("+" + a) } } i = i + 1 } return 0 } // ---- AST rename (Box/Method) within a single file ---- _fix_ast_rename(text, path, ren_boxes, ren_methods) { if text == null { return null } local out = text // Box rename first (affects definitions and calls) if ren_boxes != null { local i = 0 while i < ren_boxes.size() { local rb = ren_boxes.get(i) out = me._rename_box_text(out, rb.get("old"), rb.get("new")) i = i + 1 } } // Method renames scoped by box, use AST spans for definitions if ren_methods != null && ren_methods.size() > 0 { // parse AST once to get method span lines local ast = HakoParserCoreBox.parse(out) local i2 = 0 while i2 < ren_methods.size() { local rm = ren_methods.get(i2) out = me._rename_method_text(out, ast, rm.get("box"), rm.get("old"), rm.get("new")) i2 = i2 + 1 } } return out } _rename_box_text(text, old, neu) { if text == null || old == null || neu == null { return text } local lines = me._split_lines(text) local changed = 0 // Def line: static box Old { → replace Old only after the prefix local i = 0 while i < lines.size() { local ln = lines.get(i) local pref = "static box " + old + " " if me._ltrim(ln).indexOf(pref) == 0 { lines.set(i, ln.replace(pref, "static box " + neu + " ")) changed = 1 } // Calls: occurrences of "Old." → "New." when "Old." is found if ln.indexOf(old + ".") >= 0 { // simplistic boundary: avoid replacing inside quoted strings is out of scope (MVP) lines.set(i, ln.replace(old + ".", neu + ".")) if lines.get(i) != ln { changed = 1 } } i = i + 1 } if changed == 0 { return text } // join local j=0; local buf=""; while j Box local dot = qual.lastIndexOf(".") if dot > 0 { local box_name = qual.substring(0, dot) if dead.has(box_name) == 1 { i = i + 1; continue } } } } res.push(s) i = i + 1 } return res } _is_hc011(s) { if s == null { return 0 } if s.indexOf("[HC011]") == 0 { return 1 } return 0 } _is_hc012(s) { if s == null { return 0 } if s.indexOf("[HC012]") == 0 { return 1 } return 0 } _extract_box_from_hc012(s) { // format: [HC012] dead static box (never referenced): Name if s == null { return null } local p = s.lastIndexOf(":") if p < 0 { return null } local name = s.substring(p+1) // trim spaces local t = 0; while t < name.length() { local c=name.substring(t,t+1); if c==" "||c=="\t" { t=t+1; continue } break } if t > 0 { name = name.substring(t) } return name } _extract_method_from_hc011(s) { // format: [HC011] ... :: Box.method/arity if s == null { return null } local p = s.lastIndexOf("::") if p < 0 { return null } local qual = s.substring(p+2) // trim leading space local t = 0; while t < qual.length() { local c=qual.substring(t,t+1); if c==" "||c=="\t" { t=t+1; continue } break } if t > 0 { qual = qual.substring(t) } return qual } _parse_msg_to_diag(msg, path) { if msg == null { return null } // Expect prefixes like: [HC002] ... path:LINE or [HC011] ... :: Method local rule = "HC000"; local i0 = msg.indexOf("["); local i1 = msg.indexOf("]") if i0 == 0 && i1 > 1 { rule = msg.substring(1, i1) } // find last ':' as line separator local line = 1 local p = msg.lastIndexOf(":") if p > 0 { // try parse after ':' as int (consume consecutive trailing digits) local tail = msg.substring(p+1) // remove leading spaces local q = 0; while q < tail.length() { local c=tail.substring(q,q+1); if c==" "||c=="\t" { q = q + 1 continue } break } local digits = ""; while q < tail.length() { local c=tail.substring(q,q+1); if c>="0" && c<="9" { digits = digits + c; q = q + 1; continue } break } if digits != "" { line = me._atoi(digits) } } // message: drop path and line suffix local message = msg // naive quickFix suggestions local qf = "" if rule == "HC002" { qf = "Replace include with using (alias)" } if rule == "HC003" { qf = "Quote module name: using \"mod\"" } if rule == "HC010" { qf = "Move assignment into a method (lazy init)" } if rule == "HC011" { qf = "Remove or reference the dead method from an entrypoint" } local sev = "warning" if rule == "HC001" || rule == "HC002" || rule == "HC010" || rule == "HC011" { sev = "error" } if rule == "HC003" || rule == "HC020" { sev = "warning" } local d = new MapBox(); d.set("file", path); d.set("line", line); d.set("rule", rule); d.set("message", message); d.set("quickFix", qf); d.set("severity", sev) return d } _render_dot_multi(irs) { // Minimal, safe DOT renderer (inline) to avoid type issues // Build nodes/edges from IR we just created in this CLI print("digraph Hako {") // node/edge sets local nodes = new MapBox() local edges = new MapBox() // collect if irs != null { local i = 0 while i < irs.size() { local ir = irs.get(i) if ir != null { // methods local ms = ir.get("methods") if ms != null { local mi = 0 while mi < ms.size() { local name = ms.get(mi) if name != null { nodes.set(name, 1) } mi = mi + 1 } } // calls local cs = ir.get("calls") if cs != null { local ci = 0 while ci < cs.size() { local c = cs.get(ci) if c != null { local src = c.get("from") local dst = c.get("to") if src != null && dst != null { local key = src + "\t" + dst edges.set(key, 1) } } ci = ci + 1 } } } i = i + 1 } } // print edges // Also print nodes to ensure isolated nodes are present (minimal) // edges // NOTE: MapBox has no key iterator; we use a simple heuristic by re‑scanning possible sources // For stability in this MVP, emit edges for calls we still have in IRs if irs != null { local i2 = 0 while i2 < irs.size() { local ir2 = irs.get(i2) if ir2 != null { local cs2 = ir2.get("calls") if cs2 != null { local cj = 0 while cj < cs2.size() { local c2 = cs2.get(cj) if c2 != null { local s = c2.get("from") local d = c2.get("to") if s != null && d != null { print(" \"" + s + "\" -> \"" + d + "\";") } } cj = cj + 1 } } } i2 = i2 + 1 } } print("}") return 0 } _sort_diags(diags) { if diags == null { return new ArrayBox() } local out = new ArrayBox(); local i=0; while i 0 { local d = v % 10; tmp = digits.substring(d,d+1) + tmp; v = v / 10 } out = tmp return out } _json_quote(s) { if s == null { return "\"\"" } local out = ""; local i = 0; local n = s.length() while i < n { local ch = s.substring(i,i+1) if ch == "\\" { out = out + "\\\\" } else { if ch == "\"" { out = out + "\\\"" } else { if ch == "\n" { out = out + "\\n" } else { if ch == "\r" { out = out + "\\r" } else { if ch == "\t" { out = out + "\\t" } else { out = out + ch } } } } } i = i + 1 } return "\"" + out + "\"" } _atoi(s) { if s == null { return 0 } local n = s.length(); if n == 0 { return 0 } local i = 0; local v = 0 local digits = "0123456789" while i < n { local ch = s.substring(i,i+1) // stop at first non-digit if ch < "0" || ch > "9" { break } // map to int via indexOf local pos = digits.indexOf(ch) if pos < 0 { break } v = v * 10 + pos i = i + 1 } return v } _log_stderr(msg) { // Log rule execution context to help diagnose VM errors // Note: This logs to stdout, but test framework filters it out during JSON extraction // In the future, this should use error() extern function for true stderr output print(msg) return 0 } } // Default entry: Main.main so runner resolves without explicit --entry static box Main { method main(args) { return HakoAnalyzerBox.run(args) } }