Files
hakorune/tools/hako_check/cli.hako
nyash-codex 000335c32e feat(hako_check): Phase 154 MIR CFG integration & HC020 dead block detection
Implements block-level unreachable code detection using MIR CFG information.
Complements Phase 153's method-level HC019 with fine-grained analysis.

Core Infrastructure (Complete):
- CFG Extractor: Extract block reachability from MirModule
- DeadBlockAnalyzerBox: HC020 rule for unreachable blocks
- CLI Integration: --dead-blocks flag and rule execution
- Test Cases: 4 comprehensive patterns (early return, constant false, infinite loop, break)
- Smoke Test: Validation script for all test cases

Implementation Details:
- src/mir/cfg_extractor.rs: New module for CFG→JSON extraction
- tools/hako_check/rules/rule_dead_blocks.hako: HC020 analyzer box
- tools/hako_check/cli.hako: Added --dead-blocks flag and HC020 integration
- apps/tests/hako_check/test_dead_blocks_*.hako: 4 test cases

Architecture:
- Follows Phase 153 boxed modular pattern (DeadCodeAnalyzerBox)
- Optional CFG field in Analysis IR (backward compatible)
- Uses MIR's built-in reachability computation
- Gracefully skips if CFG unavailable

Known Limitation:
- CFG data bridge pending (Phase 155): analysis_consumer.hako needs MIR access
- Current: DeadBlockAnalyzerBox implemented, but CFG not yet in Analysis IR
- Estimated 2-3 hours to complete bridge in Phase 155

Test Coverage:
- Unit tests: cfg_extractor (simple CFG, unreachable blocks)
- Integration tests: 4 test cases ready (will activate with bridge)
- Smoke test: tools/hako_check_deadblocks_smoke.sh

Documentation:
- phase154_mir_cfg_inventory.md: CFG structure investigation
- phase154_implementation_summary.md: Complete implementation guide
- hako_check_design.md: HC020 rule documentation

Next Phase 155:
- Implement CFG data bridge (extract_mir_cfg builtin)
- Update analysis_consumer.hako to call bridge
- Activate HC020 end-to-end testing

🤖 Generated with Claude Code (https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 15:00:45 +09:00

867 lines
36 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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<Map{old,new}>
local ren_methods = new ArrayBox() // Array<Map{box,old,new}>
// 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
// Support inline sources: --source-file <path> <text>. 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 == "--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 <Old> <New>"); 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 <Box> <Old> <New>"); 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 <path> <text>"); 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)
} 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<out.size() { cur_text = cur_text + out.get(k); if k<out.size()-1 { cur_text = cur_text + "\n" } k=k+1 }
// 逆順に処理して行番号のズレを回避
local ai = alias_lines.size() - 1
while ai >= 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<out.size() { if out.get(z)!=null { out2.push(out.get(z)) } z=z+1 }
out = out2
}
// HC014: missing entrypoint → 空スタブ提案
local joined = ""; local jj=0; while jj<out.size() { joined = joined + out.get(jj); if jj<out.size()-1 { joined = joined + "\n" } jj=jj+1 }
if me._has_entrypoint(joined) == 0 {
// append stub
local stub = "\nstatic box Main { method main(args) { return 0 } }"
joined = joined + stub
changed = 1
// split again for diff
out = me._split_lines(joined)
}
if changed == 0 { return text }
// Join with \n
local j = 0; local buf = ""; while j < out.size() { buf = buf + out.get(j); if j < out.size()-1 { buf = buf + "\n" } j = j + 1 }
return buf
}
_alias_unused(text, alias) {
if text == null || alias == null { return 0 }
// 使用判定は保守的に: 'Alias.' / 'Alias(' / 'Alias ' / 'Alias::' のいずれかが存在すれば使用中とみなす
local pats = new ArrayBox(); pats.push(alias+"."); pats.push(alias+"("); pats.push(alias+" "); pats.push(alias+"::")
local i = 0; while i < pats.size() {
if text.indexOf(pats.get(i)) >= 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 replacementMVP
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<lines.size() { buf = buf + lines.get(j); if j<lines.size()-1 { buf = buf + "\n" } j=j+1 }
return buf
}
_rename_method_text(text, ast, box, old, neu) {
// NOTE(Phase 73): AST-based method rename QuickFix is temporarily disabled
// for Stage-3 parser compatibility. For now, return original text unchanged.
if text == null { return text }
return text
}
_print_refactor_plan_json(path, ren_boxes, ren_methods) {
// Print a small JSON describing intended refactors and a shell apply skeleton
print("{")
print(" \"file\": " + me._json_quote(path) + ",")
// boxes
print(" \"rename_boxes\": [")
if ren_boxes != null {
local i=0; while i<ren_boxes.size() {
local rb = ren_boxes.get(i)
print(" {\"old\": " + me._json_quote(rb.get("old")) + ", \"new\": " + me._json_quote(rb.get("new")) + "}" + (i<ren_boxes.size()-1?",":""))
i = i + 1
}
}
print(" ],")
// methods
print(" \"rename_methods\": [")
if ren_methods != null {
local j=0; while j<ren_methods.size() {
local rm = ren_methods.get(j)
print(" {\"box\": " + me._json_quote(rm.get("box")) + ", \"old\": " + me._json_quote(rm.get("old")) + ", \"new\": " + me._json_quote(rm.get("new")) + "}" + (j<ren_methods.size()-1?",":""))
j = j + 1
}
}
print(" ],")
// apply script (sed-based skeleton; manual review required)
print(" \"apply_script\": " + me._json_quote("#!/bin/sh\n# Review before apply\nFILE=\"" + path + "\"\n# Box renames\n"))
if ren_boxes != null {
local i2=0; while i2<ren_boxes.size() {
local rb2=ren_boxes.get(i2)
print(me._json_quote("sed -i -E 's/\\b" + rb2.get("old") + "\\./" + rb2.get("new") + "./g' \"$FILE\"\n") + "+")
i2 = i2 + 1
}
}
print(me._json_quote("# Method renames\n"))
if ren_methods != null {
local j2=0; while j2<ren_methods.size() {
local rm2=ren_methods.get(j2)
print(me._json_quote("sed -i -E 's/" + rm2.get("box") + "\\." + rm2.get("old") + "\\(/" + rm2.get("box") + "." + rm2.get("new") + "(/g' \"$FILE\"\n") + "+")
j2 = j2 + 1
}
}
print(me._json_quote("# Definition lines may require manual adjustment if formatting differs\n"))
print("}")
return 0
}
_needs_ast(only, skip) {
// Parse AST when duplicate_method is explicitly requested (needs method spans/definitions precision).
if only != null {
local i = 0; while i < only.size() { local k = only.get(i); if k == "duplicate_method" { return 1 } if k == "force_ast" { return 1 } i = i + 1 }
return 0
}
// Default (all rules): avoid AST to reduce VM/PHI risks; rely on IR/text fallbacks.
return 0
}
_needs_ir(only, skip) {
// IR is needed for rules that analyze methods, calls, or boxes
// Text-only rules (brace_heuristics, non_ascii_quotes, etc.) don't need IR
if only != null {
local i = 0
while i < only.size() {
local k = only.get(i)
// Rules that need IR
if k == "dead_methods" { return 1 }
if k == "dead_static_box" { return 1 }
if k == "dead_code" { return 1 }
if k == "duplicate_method" { return 1 }
if k == "missing_entrypoint" { return 1 }
if k == "arity_mismatch" { return 1 }
if k == "include_forbidden" { return 1 }
if k == "force_ast" { return 1 }
i = i + 1
}
// If we get here, only text-based rules are active (e.g., brace_heuristics)
return 0
}
// Default (all rules): need IR
return 1
}
_parse_csv(s) {
if s == null { return null }
local arr = new ArrayBox();
local cur = ""; local i = 0; while i < s.length() {
local ch = s.substring(i,i+1)
if ch == "," { if cur != "" { arr.push(cur) }; cur = ""; i = i + 1; continue }
cur = cur + ch; i = i + 1
}
if cur != "" { arr.push(cur) }
return arr
}
_rule_enabled(only, skip, key) {
if key == null { return 1 }
if only != null {
// enabled only if key is present
local i = 0; while i < only.size() { if only.get(i) == key { return 1 } i = i + 1 }
return 0
}
if skip != null {
local j = 0; while j < skip.size() { if skip.get(j) == key { return 0 } j = j + 1 }
}
return 1
}
// Build dead-box set and drop HC011 for methods inside dead boxes
_suppress_overlap(out) {
if out == null { return new ArrayBox() }
// collect dead boxes from HC012 messages
local dead = new MapBox()
local i = 0
while i < out.size() {
local s = out.get(i)
if me._is_hc012(s) == 1 {
local bx = me._extract_box_from_hc012(s)
if bx != null { dead.set(bx, 1) }
}
i = i + 1
}
// filter
local res = new ArrayBox()
i = 0
while i < out.size() {
local s = out.get(i)
if me._is_hc011(s) == 1 {
local qual = me._extract_method_from_hc011(s)
if qual != null {
// method qual: Box.method/arity -> 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 rescanning 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<diags.size() { out.push(diags.get(i)); i=i+1 }
local n = out.size(); local a=0; while a<n { local b=a+1; while b<n {
local da = out.get(a); local db = out.get(b)
local ka = da.get("file") + ":" + me._itoa(da.get("line"))
local kb = db.get("file") + ":" + me._itoa(db.get("line"))
if kb < ka { local tmp=out.get(a); out.set(a,out.get(b)); out.set(b,tmp) }
b=b+1 } a=a+1 }
return out
}
_itoa(n) {
// assume non-negative small ints for diagnostics
local v = 0 + n
if v == 0 { return "0" }
local out = ""; local digits = "0123456789"
local tmp = ""
while v > 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) } }