@@ -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 전용 ───
499681fn 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
779965let pass = len(violations) == 0
0 commit comments