// 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.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 local no_ast = 0 // single-pass parse: handle options in-place and collect sources local i = 0 local fail = 0 local irs = new ArrayBox() local diags = new ArrayBox() // 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 == "--no-ast" { no_ast = 1; 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 } // 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) // analysis local ir = HakoAnalysisBuilderBox.build_from_source_flags(text, p, no_ast) // parse AST once for AST-capable rules(no_ast=1 のときはスキップ) local ast = null if no_ast == 0 { 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() RuleIncludeForbiddenBox.apply_ast(ast, p, out) // Fallback to text scan if AST did not detect any include if out.size() == before { RuleIncludeForbiddenBox.apply(text, p, out) } } else { RuleIncludeForbiddenBox.apply(text, p, out) } RuleUsingQuotedBox.apply(text, p, out) RuleUnusedAliasBox.apply(text, p, out) RuleStaticTopAssignBox.apply(text, p, out) RuleGlobalAssignBox.apply(text, p, out) // HC017 must inspect original text prior to sanitize RuleNonAsciiQuotesBox.apply(text_raw, p, out) RuleJsonfragUsageBox.apply(text, p, out) // rules that need IR (enable dead code detection) local before_n = out.size() 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)) } // flush // for j in 0..(n-1) local n = out.size(); if n > 0 && fmt == "text" { local j = 0; while j < n { print(out.get(j)); j = j + 1 } } // also collect diagnostics for json-lsp local j2 = 0; while j2 < n { local msg = out.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 and convert fancy quotes to ASCII 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 } // fancy double quotes → ASCII if ch == "“" || ch == "”" { out = out.concat("\""); i2 = i2 + 1; continue } // fancy single quotes → ASCII if ch == "‘" || ch == "’" { out = out.concat("'"); i2 = i2 + 1; continue } 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 } _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) { // Delegate to Graphviz renderer (includes edges) GraphvizRenderBox.render_multi(irs) 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 } } // Default entry: Main.main so runner resolves without explicit --entry static box Main { method main(args) { return HakoAnalyzerBox.run(args) } }