Files
hakorune/tools/hako_check/analysis_consumer.hako
nyash-codex 6255877e48 Phase 21.4: HC012 Dead Static Box + HC013 Duplicate Method実装完了
## 実装内容

### HC012: Dead Static Box
- 未参照static boxを検出
- IR boxes配列活用、calls情報から参照チェック
- テスト: HC012_dead_static_box/{ok,ng}.hako + expected.json

### HC013: Duplicate Method
- 同名・同arityメソッド重複検出
- Box内でメソッド署名の一意性チェック
- テスト: HC013_duplicate_method/{ok,ng}.hako + expected.json

### 🔥 Critical Bug Fix: parser_core.hako arity計算修正
- **問題**: arityが「カンマの数」を返していた(add(a,b) → 1)
- **修正**: `if any == 1 { arity = arity + 1 }` に変更
- **影響**: 全メソッドのarity計算が正しくなった(HC015等に波及)

### Infrastructure改善
- analysis_consumer.hako: _ensure_array()ヘルパー導入
  - MapBox.get().push()問題の根本解決
  - uses/methods/calls/boxes全てで安全なpush実現
- run_tests.sh: NYASH_JSON_ONLY=1で出力純度確保
- cli.hako: HC012/HC013統合、デバッグ出力追加

## テスト結果
 HC011_dead_methods: OK
 HC012_dead_static_box: OK
 HC013_duplicate_method: OK
 HC016_unused_alias: OK
[TEST/SUMMARY] all green

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-08 02:59:54 +09:00

385 lines
13 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/analysis_consumer.hako — HakoAnalysisBuilderBox (MVP)
// Build a minimal Analysis IR from raw .hako source (no Rust parser needed).
// IR (MapBox): {
// path: String,
// uses: Array<String>,
// boxes: Array<Map{name,is_static,methods:Array<Map{name,arity,span}}}>,
// methods: Array<String> (qualified: Box.method/arity),
// calls: Array<Map{from,to}},
// entrypoints: Array<String>
// }
using selfhost.shared.common.string_helpers as Str
using tools.hako_parser.parser_core as HakoParserCoreBox
static box HakoAnalysisBuilderBox {
build_from_source(text, path) { return me.build_from_source_flags(text, path, 0) }
build_from_source_flags(text, path, no_ast) {
local ir = new MapBox()
ir.set("path", path)
ir.set("uses", new ArrayBox())
ir.set("boxes", new ArrayBox())
ir.set("methods", new ArrayBox())
ir.set("calls", new ArrayBox())
ir.set("method_spans", new ArrayBox())
ir.set("source", text)
local eps = new ArrayBox(); eps.push("Main.main"); eps.push("main"); ir.set("entrypoints", eps)
// debug disabled in strict environments
local debug = 0
// Prefer AST (Hako Parser) if possible
local ast = null
if no_ast == 0 { ast = HakoParserCoreBox.parse(text) }
if ast != null {
// uses (with fallback for backward compat)
local uses = ast.get("uses"); if uses == null { uses = ast.get("uses_arr") }
if uses != null { local ui=0; while ui<uses.size() { me._ensure_array(ir, "uses").push(uses.get(ui)); ui=ui+1 } }
// methods (qualified: Box.method/arity) and boxes metadata
local boxes_ast = ast.get("boxes"); if boxes_ast == null { boxes_ast = ast.get("boxes_arr") }
if boxes_ast != null {
local bi=0
while bi<boxes_ast.size() {
local b = boxes_ast.get(bi)
local name = b.get("name")
local is_static = b.get("is_static")
local span_line = b.get("span_line")
// Add box to IR boxes array for HC012 dead static box detection
local box_info = new MapBox()
box_info.set("name", name)
box_info.set("is_static", is_static)
box_info.set("methods", new ArrayBox())
if span_line != null { box_info.set("span_line", span_line) }
local ms = b.get("methods")
if ms != null {
local mi=0
while mi<ms.size() {
local m = ms.get(mi)
local qual = name + "." + m.get("name") + "/" + me._itoa(m.get("arity"))
me._ensure_array(ir, "methods").push(qual)
// capture start line spans for later source mapping
local spans = me._ensure_array(ir, "method_spans")
local rec = new MapBox(); rec.set("name", qual); rec.set("line", m.get("span"))
spans.push(rec)
// Also add method metadata to box_info
local method_info = new MapBox()
method_info.set("name", m.get("name"))
method_info.set("arity", m.get("arity"))
method_info.set("span", m.get("span"))
local box_info_methods = box_info.get("methods")
box_info_methods.push(method_info)
mi = mi + 1
}
}
// Add box_info to IR boxes array
me._ensure_array(ir, "boxes").push(box_info)
bi = bi + 1
}
}
}
// 1) collect using linesAST が空のときのみテキスト走査)
local lines = me._split_lines(text)
if ir.get("uses") == null || ir.get("uses").size() == 0 {
local _i = 0
while _i < lines.size() {
local ln = me._ltrim(lines.get(_i))
if ln.indexOf('using "') == 0 {
// using "pkg.name" as Alias
local q1 = ln.indexOf('"')
local q2 = -1
if q1 >= 0 { q2 = ln.indexOf('"', q1+1) }
if q1 >= 0 && q2 > q1 { me._ensure_array(ir, "uses").push(ln.substring(q1+1, q2)) }
}
_i = _i + 1
}
}
// 2) scan static/box and methods when AST did not populate any methods or boxes
local need_method_scan = 1
if ir.get("methods") != null { if ir.get("methods").size() > 0 { need_method_scan = 0 } }
// Also scan if boxes array is empty (for HC012 dead static box detection)
local need_box_scan = 0
if ir.get("boxes") != null { if ir.get("boxes").size() == 0 { need_box_scan = 1 } }
if need_method_scan == 1 || need_box_scan == 1 {
// debug noop
local boxes_arr = me._ensure_array(ir, "boxes")
local methods_arr = me._ensure_array(ir, "methods")
local cur_name = null
local cur_is_static = 0
local i2 = 0
while i2 < lines.size() {
local ln = me._ltrim(lines.get(i2))
// static box Name {
if ln.indexOf("static box ") == 0 {
local rest = ln.substring("static box ".length())
local sp = me._upto(rest, " {")
cur_name = sp
cur_is_static = 1
local b = new MapBox(); b.set("name", cur_name); b.set("is_static", true); b.set("methods", new ArrayBox())
boxes_arr.push(b)
i2 = i2 + 1
continue
}
// method foo(args) {
if ln.indexOf("method ") == 0 {
if cur_name == null { cur_name = "Main" }
local rest = ln.substring("method ".length())
local p = rest.indexOf("(")
local mname = (p>0) ? rest.substring(0,p) : rest
mname = me._rstrip(mname)
local arity = me._count_commas_in_parens(rest)
local method = new MapBox(); method.set("name", mname); method.set("arity", arity); method.set("span", (i2+1))
// Safe push pattern: get box, then get its methods array, then push
local last_box = boxes_arr.get(boxes_arr.size()-1)
local box_methods = last_box.get("methods")
box_methods.push(method)
methods_arr.push(cur_name + "." + mname + "/" + me._itoa(arity))
i2 = i2 + 1
continue
}
// box boundary heuristic
if ln == "}" { cur_name = null; cur_is_static = 0; }
i2 = i2 + 1
}
}
// Final fallback: super simple scan over raw text if still no methods
if ir.get("methods").size() == 0 { me._scan_methods_fallback(text, ir) }
// 3) calls: naive pattern Box.method( or Alias.method( — arity推定付き
// For MVP, we scan whole text and link within same file boxes only.
// debug noop
local calls_arr = me._ensure_array(ir, "calls")
local i3 = 0
while i3 < lines.size() {
local ln = lines.get(i3)
// source context: try to infer last seen method
// We fallback to "Main.main" when unknown
local src = me._last_method_for_line(ir, i3+1)
local pos = 0
local L = ln.length()
local k = 0
while k <= L {
local dot = ln.indexOf(".", pos)
if dot < 0 { break }
// find ident before '.' and after '.'
local lhs = me._scan_ident_rev(ln, dot-1)
local rhs = me._scan_ident_fwd(ln, dot+1)
if lhs != null && rhs != null {
// infer arity by scanning parentheses immediately after method
local ar = me._infer_call_arity(ln, dot + 1 + rhs.length())
local tgt = lhs + "." + rhs + "/" + me._itoa(ar)
// record
local c = new MapBox(); c.set("from", src); c.set("to", tgt)
calls_arr.push(c)
}
pos = dot + 1
k = k + 1
}
i3 = i3 + 1
}
return ir
}
// utilities
_ltrim(s) { return me._ltrim_chars(s, " \t") }
_itoa(n) { 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 }
_split_lines(s) {
local arr = new ArrayBox(); if s == null { return arr }
local n = s.length(); local last = 0; local i = 0
loop (i < n) { local ch = s.substring(i,i+1); if ch == "\n" { arr.push(s.substring(last,i)); last = i+1 } i = i + 1 }
if last <= n { arr.push(s.substring(last)) }
return arr
}
_rstrip(s) {
local n = s.length()
local last = n
// scan from end using reverse index
local r = 0
while r < n {
local i4 = n-1-r
local c = s.substring(i4, i4+1)
if c != " " && c != "\t" { last = i4+1; break }
if r == n-1 { last = 0 }
r = r + 1
}
return s.substring(0, last)
}
_ltrim_chars(s, cs) {
local n = s.length()
local head = 0
local idx = 0
while idx < n {
local ch = s.substring(idx, idx+1)
if ch != " " && ch != "\t" { head = idx; break }
if idx == n-1 { head = n }
idx = idx + 1
}
return s.substring(head)
}
_upto(s, needle) {
local p = s.indexOf(needle)
if p < 0 { return me._rstrip(s) }
return s.substring(0,p)
}
_count_commas_in_parens(rest) {
// method foo(a,b,c) → 3 ; if empty → 0
local p1 = rest.indexOf("("); local p2 = rest.indexOf(")", p1+1)
if p1 < 0 || p2 < 0 || p2 <= p1+1 { return 0 }
local inside = rest.substring(p1+1, p2)
local cnt = 1; local n=inside.length(); local any=0
local i5 = 0
while i5 < n {
local c = inside.substring(i5,i5+1)
if c == "," { cnt = cnt + 1 }
if c != " " && c != "\t" { any = 1 }
i5 = i5 + 1
}
if any==0 { return 0 }
return cnt
}
_scan_methods_fallback(text, ir) {
if text == null { return 0 }
local methods = ir.get("methods")
local box_name = "Main"
// find "static box Name" to prefer given name
local pbox = text.indexOf("static box ")
if pbox >= 0 {
local after = pbox + "static box ".length()
local name = ""
local i = after
loop (i < text.length()) {
local ch = text.substring(i,i+1)
if (ch >= "A" && ch <= "Z") || (ch >= "a" && ch <= "z") || ch == "_" || (ch >= "0" && ch <= "9") {
name = name + ch
i = i + 1
continue
}
break
}
if name != "" { box_name = name }
}
// scan for "method " occurrences
local pos = 0
local n = text.length()
loop (pos < n) {
local k = text.indexOf("method ", pos)
if k < 0 { break }
local i = k + "method ".length()
// read ident
local mname = ""
loop (i < n) {
local ch2 = text.substring(i,i+1)
if (ch2 >= "A" && ch2 <= "Z") || (ch2 >= "a" && ch2 <= "z") || ch2 == "_" || (ch2 >= "0" && ch2 <= "9") {
mname = mname + ch2
i = i + 1
continue
}
break
}
// look ahead for params (...) and count commas
local ar = 0
local lp = text.indexOf("(", i)
if lp >= 0 {
local rp = text.indexOf(")", lp+1)
if rp > lp+1 {
local inside = text.substring(lp+1, rp)
local any = 0; local c = 0; local j=0
loop (j < inside.length()) {
local ch3=inside.substring(j,j+1)
if ch3 == "," { c = c + 1 }
if ch3 != " " && ch3 != "\t" { any = 1 }
j = j + 1
}
if any == 1 { ar = c + 1 }
}
}
if mname != "" { methods.push(box_name + "." + mname + "/" + me._itoa(ar)) }
pos = i
}
return methods.size()
}
_scan_ident_rev(s, i) {
if i<0 { return null }
local n = i
local start = 0
local rr = 0
while rr <= n {
local j = i - rr
local c = s.substring(j, j+1)
if me._is_ident_char(c) == 0 { start = j+1; break }
if j == 0 { start = 0; break }
rr = rr + 1
}
if start>i { return null }
return s.substring(start, i+1)
}
_scan_ident_fwd(s, i) {
local n=s.length(); if i>=n { return null }
local endp = i
local off = 0
while off < n {
local j = i + off
if j >= n { break }
local c = s.substring(j, j+1)
if me._is_ident_char(c) == 0 { endp = j; break }
if j == n-1 { endp = n; break }
off = off + 1
}
if endp == i { return null }
return s.substring(i, endp)
}
_is_ident_char(c) {
if c == "_" { return 1 }
if c >= "A" && c <= "Z" { return 1 }
if c >= "a" && c <= "z" { return 1 }
if c >= "0" && c <= "9" { return 1 }
return 0
}
_last_method_for_line(ir, line_num) {
// Conservative: return default entry when spans are not guaranteed to be maps
// This avoids runtime errors when method_spans is absent or malformed in MVP builds.
return "Main.main"
}
_infer_call_arity(line, after_name_pos) {
// Count commas between the first '(' after method name and its matching ')'
if line == null { return 0 }
local n = line.length()
local i = after_name_pos
// seek to '('
while i < n {
local ch = line.substring(i,i+1)
if ch == "(" { break }
// stop early if we hit non-space before '('
if ch != " " && ch != "\t" { return 0 }
i = i + 1
}
if i >= n || line.substring(i,i+1) != "(" { return 0 }
// scan until matching ')', no nesting assumed for MVP
local any = 0; local commas = 0
i = i + 1
while i < n {
local ch = line.substring(i,i+1)
if ch == ")" { break }
if ch == "," { commas = commas + 1 }
if ch != " " && ch != "\t" { any = 1 }
i = i + 1
}
if any == 0 { return 0 }
return commas + 1
}
_ensure_array(m, key) {
if m == null { return new ArrayBox() }
// If key absent, create and return a new ArrayBox
if m.has(key) == 0 { local arr = new ArrayBox(); m.set(key, arr); return arr }
// Otherwise return the existing value
return m.get(key)
}
}
static box HakoAnalysisBuilderMain { method main(args) { return 0 } }