Skip to content

Commit 77b0fb2

Browse files
dancinlifeclaude
andcommitted
feat(harness): rules-as-data 엔진 + anima AN1/AN3/AN7 JSONL
shared/harness/lint.hexa + check_project_rules: - shared/rules/<project>.lint.jsonl 자동 로드 (한 줄 = 한 규칙) - 지원 kind 7종: * grep — 단일 패턴 매칭 * grep_combo — all[] 전부 매칭 (AND) * grep_any — any[] 하나라도 매칭 (OR) * ext_blacklist — 확장자 금지 * ext_blacklist_in_path — path_any + ext 조합 * filename — basename 금칙어 * require_entry — entry_patterns 없으면 위반 - 공통 필드: ext, exclude, path_prefix, path_any, severity, fix, allow - 헬퍼: _pr_field/_pr_array/_ext_in/_any_in_path/_any_excluded shared/rules/anima.lint.jsonl (신규): - AN1 ConsciousLM + .generate() 조합 error - AN3 phi/tension/exchange 경로의 .py 파일 warn - AN7 core/ 신규 파일 중 entry_patterns 없으면 warn JSON 이스케이프 우회 — [.]/[(] 문자 클래스로 리터럴 매칭. smoke test 3종 모두 통과. 규칙 추가/변경은 이제 JSONL 한 줄 편집만. lint.hexa 코드 불변. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent bbdad03 commit 77b0fb2

2 files changed

Lines changed: 189 additions & 0 deletions

File tree

shared/harness/lint.hexa

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -495,6 +495,188 @@ fn check_ag4(path: string) -> string {
495495
return ""
496496
}
497497

498+
// ─── 프로젝트별 규칙 JSONL 자동 로드 엔진 ───
499+
// 파일: shared/rules/<project>.lint.jsonl (한 줄 = 한 규칙)
500+
// 지원 kind: grep, grep_combo, grep_any, ext_blacklist, ext_blacklist_in_path,
501+
// ext_whitelist, filename, require_entry
502+
// 규칙 추가/변경은 JSONL 파일만 편집 (엔진 코드 불변)
503+
504+
fn _pr_field(line: string, key: string) -> string {
505+
let marker = "\"" + key + "\":\""
506+
let idx = line.index_of(marker)
507+
if idx < 0 { return "" }
508+
let after = line.substring(idx + len(marker), len(line))
509+
let end = after.index_of("\"")
510+
if end < 0 { return "" }
511+
return after.substring(0, end)
512+
}
513+
514+
// "key":[...] 배열 값 추출 — 각 원소를 " 로 감싼 문자열로 반환
515+
fn _pr_array(line: string, key: string) -> [string] {
516+
let mut arr: [string] = []
517+
let marker = "\"" + key + "\":["
518+
let idx = line.index_of(marker)
519+
if idx < 0 { return arr }
520+
let after = line.substring(idx + len(marker), len(line))
521+
let end = after.index_of("]")
522+
if end < 0 { return arr }
523+
let body = after.substring(0, end)
524+
// body 안의 "..." 추출
525+
let mut i: i64 = 0
526+
while i < len(body) {
527+
let q1 = body.index_of("\"")
528+
if q1 < 0 { break }
529+
let rest = body.substring(q1 + 1, len(body))
530+
let q2 = rest.index_of("\"")
531+
if q2 < 0 { break }
532+
let val = rest.substring(0, q2)
533+
arr = arr.push(val)
534+
let consumed = q1 + 1 + q2 + 1
535+
if consumed >= len(body) { break }
536+
// 진행: body 를 자르는 대신 local slicing
537+
let new_body = body.substring(consumed, len(body))
538+
return _pr_array_continue(new_body, arr)
539+
}
540+
return arr
541+
}
542+
543+
fn _pr_array_continue(body: string, acc: [string]) -> [string] {
544+
let mut arr = acc
545+
let q1 = body.index_of("\"")
546+
if q1 < 0 { return arr }
547+
let rest = body.substring(q1 + 1, len(body))
548+
let q2 = rest.index_of("\"")
549+
if q2 < 0 { return arr }
550+
let val = rest.substring(0, q2)
551+
arr = arr.push(val)
552+
let consumed = q1 + 1 + q2 + 1
553+
if consumed >= len(body) { return arr }
554+
let next_body = body.substring(consumed, len(body))
555+
return _pr_array_continue(next_body, arr)
556+
}
557+
558+
fn _ext_in(ext: string, list: [string]) -> bool {
559+
for e in list {
560+
if _str(e) == ext { return true }
561+
}
562+
return false
563+
}
564+
565+
fn _any_in_path(path: string, parts: [string]) -> bool {
566+
for p in parts {
567+
let s = _str(p)
568+
if s != "" && path.index_of(s) >= 0 { return true }
569+
}
570+
return false
571+
}
572+
573+
fn _any_excluded(path: string, exclude: [string]) -> bool {
574+
for e in exclude {
575+
let s = _str(e)
576+
if s != "" && path.index_of(s) >= 0 { return true }
577+
}
578+
return false
579+
}
580+
581+
// kind=grep_combo: all[] 패턴이 전부 파일에 나타나면 위반
582+
fn _match_grep_combo(full: string, patterns: [string], allow: string) -> bool {
583+
for p in patterns {
584+
let pat = _str(p)
585+
if pat == "" { continue }
586+
let cmd = "grep -E '" + pat + "' '" + full + "' 2>/dev/null | grep -v '" + allow + "' | head -1"
587+
let r = _str(exec(cmd)).trim()
588+
if r == "" { return false }
589+
}
590+
return true
591+
}
592+
593+
// kind=grep_any: any[] 패턴이 하나라도 나타나면 위반
594+
fn _match_grep_any(full: string, patterns: [string], allow: string) -> bool {
595+
for p in patterns {
596+
let pat = _str(p)
597+
if pat == "" { continue }
598+
let cmd = "grep -E '" + pat + "' '" + full + "' 2>/dev/null | grep -v '" + allow + "' | head -1"
599+
let r = _str(exec(cmd)).trim()
600+
if r != "" { return true }
601+
}
602+
return false
603+
}
604+
605+
// kind=require_entry: entry_patterns 중 하나는 있어야 함 (없으면 위반)
606+
fn _match_require_entry(full: string, patterns: [string]) -> bool {
607+
for p in patterns {
608+
let pat = _str(p)
609+
if pat == "" { continue }
610+
let cmd = "grep -cE '" + pat + "' '" + full + "' 2>/dev/null || echo 0"
611+
let c = to_int(_str(exec(cmd)).trim())
612+
if c > 0 { return false }
613+
}
614+
return true
615+
}
616+
617+
fn check_project_rules(path: string) -> string {
618+
let project = _project_name()
619+
if project == "" { return "" }
620+
let rulefile = REPO_ROOT + "/shared/rules/" + project + ".lint.jsonl"
621+
if !file_exists(rulefile) { return "" }
622+
let raw = _str(exec("cat '" + rulefile + "' 2>/dev/null")).trim()
623+
if raw == "" { return "" }
624+
let full = REPO_ROOT + "/" + path
625+
let e = ext_of(path)
626+
for ln in raw.split("\n") {
627+
let line = _str(ln).trim()
628+
if line == "" { continue }
629+
if line.starts_with("//") { continue }
630+
let id = _pr_field(line, "id")
631+
let kind = _pr_field(line, "kind")
632+
let severity = _pr_field(line, "severity")
633+
let fix = _pr_field(line, "fix")
634+
let allow = _pr_field(line, "allow")
635+
if id == "" || kind == "" { continue }
636+
let exts = _pr_array(line, "ext")
637+
if len(exts) > 0 && !_ext_in(e, exts) { continue }
638+
let exclude = _pr_array(line, "exclude")
639+
if _any_excluded(path, exclude) { continue }
640+
let prefix = _pr_field(line, "path_prefix")
641+
if prefix != "" && !path.starts_with(prefix) { continue }
642+
let path_any = _pr_array(line, "path_any")
643+
if len(path_any) > 0 && !_any_in_path(path, path_any) { continue }
644+
let mut violated = false
645+
if kind == "grep_combo" {
646+
let pats = _pr_array(line, "all")
647+
if _match_grep_combo(full, pats, allow) { violated = true }
648+
}
649+
else if kind == "grep_any" {
650+
let pats = _pr_array(line, "any")
651+
if _match_grep_any(full, pats, allow) { violated = true }
652+
}
653+
else if kind == "ext_blacklist_in_path" {
654+
// 확장자 목록 + 경로 any 둘 다 매칭 시 위반 (path_any 이미 매칭됨 + 확장자 blacklist)
655+
let bl = _pr_array(line, "ext")
656+
if _ext_in(e, bl) { violated = true }
657+
}
658+
else if kind == "ext_blacklist" {
659+
let bl = _pr_array(line, "ext")
660+
if _ext_in(e, bl) { violated = true }
661+
}
662+
else if kind == "filename" {
663+
let deny = _pr_array(line, "deny")
664+
let bn = basename_of(path)
665+
for d in deny {
666+
if _str(d) == bn { violated = true; break }
667+
}
668+
}
669+
else if kind == "require_entry" {
670+
let pats = _pr_array(line, "entry_patterns")
671+
if _match_require_entry(full, pats) { violated = true }
672+
}
673+
if violated {
674+
return id + "|" + severity + "|" + fix
675+
}
676+
}
677+
return ""
678+
}
679+
498680
// ─── AN2 (anima 전용) localStorage/sessionStorage 금지 — MemoryStore SQLite 전용 ───
499681
fn check_an2(path: string) -> string {
500682
if _project_name() != "anima" { return "" }
@@ -774,6 +956,10 @@ for f in files {
774956
// check_ag4 (airgenome 프로젝트 전용): launcher cap ≤8
775957
let v16 = check_ag4(path)
776958
if v16 != "" { violations = violations.push(path + "|" + v16) }
959+
960+
// check_project_rules: shared/rules/<project>.lint.jsonl 자동 로드 + 디스패치
961+
let v17 = check_project_rules(path)
962+
if v17 != "" { violations = violations.push(path + "|" + v17) }
777963
}
778964

779965
let pass = len(violations) == 0

shared/rules/anima.lint.jsonl

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{"id":"AN1","rule":"ConsciousLM 텍스트 generate 금지","kind":"grep_combo","ext":[".ts",".tsx",".hexa",".py"],"all":["ConsciousLM","[.]generate[(]"],"severity":"error","fix":"의식 신호 전용 — emit_signal 사용","exclude":["archive/","docs/","_archive/"],"allow":"@allow-an1"}
2+
{"id":"AN3","rule":"Phi/tension 계산 = hexa native","kind":"ext_blacklist_in_path","path_any":["phi","tension","exchange"],"ext":[".py"],"severity":"warn","fix":"hexa native (FFI → cuBLAS/NVRTC)","exclude":["archive/","docs/"],"allow":"@allow-an3"}
3+
{"id":"AN7","rule":"core/ = CLI 전용","kind":"require_entry","path_prefix":"core/","ext":[".hexa",".py"],"entry_patterns":["if __name__","fn main[(]","fn __main__"],"severity":"warn","fix":"모듈 코드는 anima/modules/ 로","exclude":["archive/","tests/","_archive/"],"allow":"@allow-an7"}

0 commit comments

Comments
 (0)