2525
2626
2727# ------------------------------------------------
28- # Identify expected prefix dynamically from file paths
28+ # Identify expected prefixes dynamically from file paths
2929# ------------------------------------------------
3030def infer_prefix_from_paths (paths ):
31+ """
32+ Returns:
33+ - prefixes: a set of allowed prefixes (including build:)
34+ - build_optional: True when commit subject does not need to be build:
35+ (i.e., when any real component — lib/tests/plugins/src — is touched)
36+ """
3137 prefixes = set ()
38+ component_prefixes = set ()
39+ build_seen = False
40+
41+ for raw in paths :
42+ # Normalize path separators (Windows compatibility)
43+ p = raw .replace (os .sep , "/" )
44+ basename = os .path .basename (p )
45+
46+ # ----- Any CMakeLists.txt → build: candidate -----
47+ if basename == "CMakeLists.txt" :
48+ build_seen = True
49+
50+ # ----- lib/ → lib: -----
51+ if p .startswith ("lib/" ):
52+ component_prefixes .add ("lib:" )
53+
54+ # ----- tests/ → tests: -----
55+ if p .startswith ("tests/" ):
56+ component_prefixes .add ("tests:" )
3257
33- for p in paths :
58+ # ----- plugins/<name>/ → <name>: -----
3459 if p .startswith ("plugins/" ):
3560 parts = p .split ("/" )
36- prefix = parts [1 ]
37- prefixes .add (f"{ prefix } :" )
38- continue
61+ if len (parts ) > 1 :
62+ component_prefixes .add (f"{ parts [1 ]} :" )
3963
64+ # ----- src/ → flb_xxx.* → xxx: OR src/<dir>/ → <dir>: -----
4065 if p .startswith ("src/" ):
4166 filename = os .path .basename (p )
4267 if filename .startswith ("flb_" ):
4368 core = filename [4 :].split ("." )[0 ]
44- prefixes .add (f"{ core } :" )
45- continue
69+ component_prefixes .add (f"{ core } :" )
70+ else :
71+ parts = p .split ("/" )
72+ if len (parts ) > 1 :
73+ component_prefixes .add (f"{ parts [1 ]} :" )
4674
47- directory = p .split ("/" )[1 ]
48- prefixes .add (f"{ directory } :" )
49- continue
75+ # prefixes = component prefixes + build: if needed
76+ prefixes |= component_prefixes
77+ if build_seen :
78+ prefixes .add ("build:" )
5079
51- return prefixes
80+ # build_optional:
81+ # True if ANY real component (lib/tests/plugins/src) was modified.
82+ # False only when modifying build system files alone.
83+ build_optional = len (component_prefixes ) > 0
84+
85+ return prefixes , build_optional
5286
5387
5488# ------------------------------------------------
@@ -84,27 +118,27 @@ def detect_bad_squash(body):
84118
85119
86120# ------------------------------------------------
87- # Validate commit per test expectations
121+ # Validate commit based on expected behavior and test rules
88122# ------------------------------------------------
89123def validate_commit (commit ):
90124 msg = commit .message .strip ()
91125 first_line , * rest = msg .split ("\n " )
92126 body = "\n " .join (rest )
93127
94- # Subject must have prefix
128+ # Subject must start with a prefix
95129 subject_prefix_match = PREFIX_RE .match (first_line )
96130 if not subject_prefix_match :
97131 return False , f"Missing prefix in commit subject: '{ first_line } '"
98132
99133 subject_prefix = subject_prefix_match .group ()
100134
101- # detect_bad_squash must run but
102- # validate_commit IGNORE bad-squash reason if it was "multiple sign-offs"
135+ # Run squash detection (but ignore multi-signoff errors)
103136 bad_squash , reason = detect_bad_squash (body )
104137
105138 # If bad squash was caused by prefix lines in body → FAIL
106139 # If list of prefix lines in body → FAIL
107140 if bad_squash :
141+ # Prefix-like lines are always fatal
108142 if "subject-like prefix" in reason :
109143 return False , f"Bad squash detected: { reason } "
110144
@@ -113,7 +147,7 @@ def validate_commit(commit):
113147 # validate_commit ignores multi signoff warnings.
114148 pass
115149
116- # Subject length
150+ # Subject length check
117151 if len (first_line ) > 80 :
118152 return False , f"Commit subject too long (>80 chars): '{ first_line } '"
119153
@@ -122,30 +156,35 @@ def validate_commit(commit):
122156 if signoff_count == 0 :
123157 return False , "Missing Signed-off-by line"
124158
125- # Determine expected prefix
159+ # Determine expected prefixes + build option flag
126160 files = commit .stats .files .keys ()
127- expected = infer_prefix_from_paths (files )
161+ expected , build_optional = infer_prefix_from_paths (files )
128162
129- # Docs/CI changes
163+ # When no prefix can be inferred (docs/tools), allow anything
130164 if len (expected ) == 0 :
131165 return True , ""
132166
133- # *** TEST EXPECTATION ***
134- # For mixed components, DO NOT return custom message.
135- # Instead: same error shape as wrong-prefix case.
136- if len (expected ) > 1 :
137- # Always fail when multiple components are touched (even if prefix matches one)
167+ expected_lower = {p .lower () for p in expected }
168+ subj_lower = subject_prefix .lower ()
169+
170+ # Subject prefix must be one of the expected ones
171+ if subj_lower not in expected_lower :
172+ expected_list = sorted (expected )
173+ expected_str = ", " .join (expected_list )
138174 return False , (
139175 f"Subject prefix '{ subject_prefix } ' does not match files changed.\n "
140- f"Expected one of: { ', ' . join ( sorted ( expected )) } "
176+ f"Expected one of: { expected_str } "
141177 )
142178
143- # Normal prefix mismatch (case-insensitive comparison)
144- only_expected = next (iter (expected ))
145- if subject_prefix .lower () != only_expected .lower ():
179+
180+ return False , f"Commit subject too long (>80 chars): '{ first_line } '"
181+
182+ # If build is NOT optional and build: exists among expected,
183+ # then subject MUST be build:
184+ if not build_optional and "build:" in expected_lower and subj_lower != "build:" :
146185 return False , (
147186 f"Subject prefix '{ subject_prefix } ' does not match files changed.\n "
148- f"Expected one of: { only_expected } "
187+ f"Expected one of: build: "
149188 )
150189
151190 return True , ""
0 commit comments