22
33from __future__ import annotations
44
5+ import fnmatch
56import hashlib
67import json
78import re
103104 r"(?i)(?:test result:\s*ok\.?[^.\n]*\b0\s+failed\b|"
104105 r"\d+\s+passed\b[^.\n]*\b0\s+failed\b|(?:all\s+tests?|tests?)\s+passed\b[^.\n]*\b0\s+failed\b)"
105106)
106- DOCUMENTATION_NAMES = frozenset (
107+ DEFAULT_DENIED_NAMES = frozenset (
107108 {
108- "agents.md" ,
109- "changelog.md" ,
110- "claude.md" ,
111- "contributing.md" ,
112- "install_for_agents.md" ,
113- "quickstart.md" ,
114- "readme.md" ,
115- "roadmap.md" ,
116- "skill.md" ,
117- "third_party_notices.md" ,
109+ "ambient-suggestions.json" ,
110+ "model-cache.json" ,
111+ "models-cache.json" ,
112+ "recommendations.json" ,
113+ "suggestions.json" ,
118114 }
119115)
120- GENERATED_SUGGESTION_STEMS = frozenset ({"recommendations" , "suggestions" })
116+ DEFAULT_DENIED_STEMS = frozenset ({"ambient-suggestions" , "recommendations" , "suggestions" })
117+ DEFAULT_DENIED_GLOBS = (
118+ "**/.codex/skills/**" ,
119+ "**/.claude/skills/**" ,
120+ "**/templates/skills/**" ,
121+ )
122+ RUN_ARTIFACT_MARKERS = ("/.brigade/work/" , "/.brigade/runs/" )
121123SOURCE_FAMILIES = ("verification" , "run" , "evaluation" , "miseledger" , "regex" )
124+ SEVERITY_DAMP_MAP = {"high" : "low" , "medium" : "low" , "low" : "low" }
125+
126+
127+ @dataclass (frozen = True )
128+ class FrictionScanConfig :
129+ deny_names : frozenset [str ] = DEFAULT_DENIED_NAMES
130+ deny_globs : tuple [str , ...] = DEFAULT_DENIED_GLOBS
122131
123132
124133@dataclass (frozen = True )
@@ -158,6 +167,78 @@ def _candidate_id(source: str, friction_type: str, text: str, *, stable_source:
158167 return f"friction-{ digest } "
159168
160169
170+ def _load_scan_config (target : Path ) -> FrictionScanConfig :
171+ defaults = FrictionScanConfig ()
172+ config_path = target / ".brigade" / "friction" / "config.json"
173+ if not config_path .is_file ():
174+ return defaults
175+ payload = _read_object (config_path )
176+ if payload is None :
177+ return defaults
178+ deny_names = defaults .deny_names
179+ raw_names = payload .get ("deny_names" )
180+ if isinstance (raw_names , list ):
181+ deny_names = frozenset ({str (name ).lower () for name in raw_names if str (name ).strip ()})
182+ deny_globs = defaults .deny_globs
183+ raw_globs = payload .get ("deny_globs" )
184+ if isinstance (raw_globs , list ):
185+ deny_globs = tuple (str (item ) for item in raw_globs if str (item ).strip ())
186+ return FrictionScanConfig (deny_names = deny_names , deny_globs = deny_globs )
187+
188+
189+ def _path_matches_deny_glob (path : Path , pattern : str ) -> bool :
190+ normalized = path .as_posix ()
191+ if fnmatch .fnmatch (normalized , pattern .lstrip ("/" )):
192+ return True
193+ if fnmatch .fnmatch (normalized , pattern ):
194+ return True
195+ if "**" not in pattern :
196+ return False
197+ anchor = pattern .replace ("**" , "" ).strip ("/" )
198+ if not anchor :
199+ return False
200+ return (
201+ f"/{ anchor } /" in f"/{ normalized } /" or normalized .endswith (f"/{ anchor } " ) or normalized .startswith (f"{ anchor } /" )
202+ )
203+
204+
205+ def _is_under_skills_tree (path : Path ) -> bool :
206+ parts = tuple (part .lower () for part in path .parts )
207+ for index , part in enumerate (parts ):
208+ if part in {".codex" , ".claude" } and index + 1 < len (parts ) and parts [index + 1 ] == "skills" :
209+ return True
210+ if part == "skills" and index > 0 and parts [index - 1 ] in {".codex" , ".claude" , "templates" }:
211+ return True
212+ return False
213+
214+
215+ def _is_denylisted_source (path : Path , config : FrictionScanConfig ) -> bool :
216+ lowered_parts = tuple (part .lower () for part in path .parts )
217+ if "memory-handoffs" in lowered_parts and "processed" in lowered_parts :
218+ return True
219+ if path .name .lower () in config .deny_names or path .stem .lower () in config .deny_names :
220+ return True
221+ if path .stem .lower () in DEFAULT_DENIED_STEMS :
222+ return True
223+ if _is_under_skills_tree (path ):
224+ return True
225+ for pattern in config .deny_globs :
226+ if _path_matches_deny_glob (path , pattern ):
227+ return True
228+ return False
229+
230+
231+ def _should_damp_prose (path : Path ) -> bool :
232+ if path .suffix .lower () != ".md" :
233+ return False
234+ posix = path .as_posix ()
235+ return not any (marker in posix for marker in RUN_ARTIFACT_MARKERS )
236+
237+
238+ def _damp_severity (severity : str ) -> str :
239+ return SEVERITY_DAMP_MAP .get (severity , severity )
240+
241+
161242def _iter_source_roots (target : Path , * , include_agent_logs : bool , agent_logs_only : bool = False ) -> list [Path ]:
162243 if agent_logs_only :
163244 roots = [Path (item ).expanduser () for item in DEFAULT_AGENT_LOG_DIRS ]
@@ -308,14 +389,6 @@ def _is_verify_receipt(path: Path) -> bool:
308389 return path .name == "receipt.json" and "verify-runs" in path .parts
309390
310391
311- def _is_noise_file (path : Path ) -> bool :
312- lowered_parts = tuple (part .lower () for part in path .parts )
313- processed_handoff = "memory-handoffs" in lowered_parts and "processed" in lowered_parts
314- return (
315- path .name .lower () in DOCUMENTATION_NAMES or path .stem .lower () in GENERATED_SUGGESTION_STEMS or processed_handoff
316- )
317-
318-
319392def _is_successful_verify_receipt (payload : dict [str , Any ]) -> bool :
320393 run_status = str (payload .get ("status" ) or "completed" )
321394 if run_status != "completed" :
@@ -386,13 +459,29 @@ def _group_candidates(items: list[dict[str, Any]]) -> tuple[list[dict[str, Any]]
386459 for candidate_id in order :
387460 bucket = indexed [candidate_id ]
388461 if len (bucket ) == 1 :
389- result .append (bucket [0 ])
462+ single = dict (bucket [0 ])
463+ raw_evidence = single .get ("evidence" )
464+ if isinstance (raw_evidence , dict ):
465+ evidence = dict (raw_evidence )
466+ evidence .setdefault ("occurrence_count" , 1 )
467+ single ["evidence" ] = evidence
468+ result .append (single )
390469 continue
391470 grouped += len (bucket ) - 1
392471 primary = dict (bucket [0 ])
393472 raw_evidence = primary .get ("evidence" )
394473 evidence = dict (raw_evidence ) if isinstance (raw_evidence , dict ) else {}
395474 evidence ["children" ] = [_evidence_child (occurrence ) for occurrence in bucket ]
475+ evidence ["occurrence_count" ] = len (bucket )
476+ source_paths = sorted (
477+ {
478+ str (occurrence .get ("evidence" , {}).get ("path" ) or "" )
479+ for occurrence in bucket
480+ if isinstance (occurrence .get ("evidence" ), dict ) and occurrence ["evidence" ].get ("path" )
481+ }
482+ )
483+ if source_paths :
484+ evidence ["source_paths" ] = source_paths
396485 primary ["evidence" ] = evidence
397486 result .append (primary )
398487 return result , grouped
@@ -472,30 +561,33 @@ def _workflow_from_path(target: Path, path: Path) -> str:
472561 return parts [0 ]
473562
474563
475- def _make_candidate (target : Path , match : Match ) -> dict [str , Any ]:
564+ def _make_candidate (target : Path , match : Match , * , damp_prose : bool = False ) -> dict [str , Any ]:
476565 try :
477566 source = str (match .path .resolve ().relative_to (target ))
478567 except ValueError :
479568 source = str (match .path )
480569 snippet = _short (match .line )
481570 text = f"{ match .title } : { snippet } "
571+ severity = _damp_severity (match .severity ) if damp_prose else match .severity
482572 return {
483- "id" : _candidate_id (source , match .friction_type , snippet , stable_source = True ),
573+ "id" : _candidate_id (source , match .friction_type , snippet , stable_source = False ),
484574 "title" : match .title ,
485575 "text" : text ,
486576 "status" : "candidate" ,
487577 "kind" : "finding" ,
488578 "source" : "friction-scan" ,
489579 "friction_type" : match .friction_type ,
490- "severity" : match . severity ,
580+ "severity" : severity ,
491581 "workflow" : _workflow_from_path (target , match .path ),
492582 "evidence" : {
493583 "path" : source ,
494584 "line" : match .line_number ,
495585 "snippet" : snippet ,
586+ "occurrence_count" : 1 ,
496587 },
497588 "suggested_fix" : "Review the evidence, decide whether this is actionable, then promote to a task, note, memory card, rule, or tool fix." ,
498589 "detection" : "regex" ,
590+ "prose_damped" : damp_prose ,
499591 }
500592
501593
@@ -732,6 +824,7 @@ def scan_payload(
732824 print ("error: --max-candidates must be a positive integer" , file = sys .stderr )
733825 return None , 2
734826
827+ scan_config = _load_scan_config (target )
735828 roots = _iter_source_roots (target , include_agent_logs = include_agent_logs , agent_logs_only = agent_logs_only )
736829 files , skipped_files = _iter_files (roots , since = since , max_files = max_files )
737830 families = (
@@ -746,23 +839,27 @@ def scan_payload(
746839 for path in files :
747840 if _is_verify_receipt (path ) or path .name in {"run.json" , "worker-results.json" , "cell.json" }:
748841 continue
842+ if _is_denylisted_source (path , scan_config ):
843+ dispositions ["regex" ]["rejected" ] += 1
844+ rejected_noise += 1
845+ continue
749846 try :
750847 resolved_path = path .expanduser ().resolve ()
751848 except OSError :
752849 resolved_path = path
753- reject_as_documentation = _is_noise_file (path )
754850 reject_as_historical = resolved_path in successful_verify_logs
851+ damp_prose = _should_damp_prose (path )
755852 matches = _scan_file (path )
756853 for match in matches :
757- if reject_as_documentation or reject_as_historical :
854+ if reject_as_historical :
758855 dispositions ["regex" ]["rejected" ] += 1
759856 rejected_noise += 1
760857 continue
761858 if _is_passing_zero_failure_line (match .line ):
762859 dispositions ["regex" ]["rejected" ] += 1
763860 rejected_noise += 1
764861 continue
765- candidate = _make_candidate (target , match )
862+ candidate = _make_candidate (target , match , damp_prose = damp_prose )
766863 candidate ["source_family" ] = "regex"
767864 regex_candidates .append (candidate )
768865 families ["regex" ] = regex_candidates
0 commit comments