Skip to content

Commit cbaf9b2

Browse files
authored
Merge pull request #6069 from makr-code/copilot/doxygen-header-completion
Improving Doxygen header generation for source files
2 parents f1cfc71 + eb3245e commit cbaf9b2

2 files changed

Lines changed: 305 additions & 38 deletions

File tree

.github/scripts/code_maturity_header_writer.py

Lines changed: 240 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@
1111
Schichten:
1212
1. Scanner: Sammelt Rohdaten (z. B. aus gap_scanner_v2, analyze_code_maturity)
1313
2. Dispatcher: Entscheidet, welche Dateien/Module aktualisiert werden
14-
3. Updater: Schreibt Header/Reports (delegiert aktuell an Legacy-Logik)
14+
3. Updater: Schreibt Header/Reports inkl. Header-Vollständigkeitsvalidierung
1515
16-
Aktuell: Nur Klassengerüst, keine Verhaltensänderung. Die eigentliche Logik bleibt vorerst im Legacy-Skript.
16+
Aktuell: Enthält die kanonische Header-Generierung für Workflow und lokale Läufe.
1717
"""
1818

1919
import sys
@@ -83,9 +83,9 @@ class CodeMaturityScanner:
8383
'production': (3, 9), 'tests': (2, 20), 'docs': (1, 15), 'documented_stub': (1, 10),
8484
}
8585

86-
def scan(self, repo_root: str = "."):
87-
repo_root = Path(repo_root)
88-
files = list(self._find_files(repo_root))
86+
def scan(self, repo_root: str = ".", target_paths: Optional[List[str]] = None):
87+
repo_root = Path(repo_root).resolve()
88+
files = list(self._find_files(repo_root, target_paths=target_paths))
8989
_debug_log('scanner', f'Found {len(files)} candidate files under {repo_root}')
9090
results = []
9191
for index, file in enumerate(files, start=1):
@@ -96,16 +96,41 @@ def scan(self, repo_root: str = "."):
9696
_debug_log('scanner', f'Analyzed {index}/{len(files)} files')
9797
return results
9898

99-
def _find_files(self, repo_root: Path):
100-
# Ziel: src/, include/, tests/, benchmarks/ rekursiv durchsuchen
101-
code_dirs = [repo_root / d for d in ("src", "include", "tests", "benchmarks") if (repo_root / d).exists()]
102-
for base_dir in code_dirs:
103-
for root, dirs, files in os.walk(base_dir):
99+
def _resolve_scan_roots(self, repo_root: Path, target_paths: Optional[List[str]]) -> List[Path]:
100+
if not target_paths:
101+
return [repo_root / d for d in ("src", "include", "tests", "benchmarks") if (repo_root / d).exists()]
102+
roots: List[Path] = []
103+
for raw in target_paths:
104+
candidate = Path(raw)
105+
resolved = candidate.resolve() if candidate.is_absolute() else (repo_root / candidate).resolve()
106+
if resolved.exists():
107+
roots.append(resolved)
108+
else:
109+
_debug_log('scanner', f'Skip missing target path: {raw}')
110+
return roots
111+
112+
def _find_files(self, repo_root: Path, target_paths: Optional[List[str]] = None):
113+
roots = self._resolve_scan_roots(repo_root, target_paths)
114+
seen: set = set()
115+
for root_path in roots:
116+
if root_path.is_file():
117+
ext = root_path.suffix.lower()
118+
if ext in self.SUPPORTED_EXTENSIONS:
119+
file_key = root_path.as_posix()
120+
if file_key not in seen:
121+
seen.add(file_key)
122+
yield root_path
123+
continue
124+
for root, dirs, files in os.walk(root_path):
104125
dirs[:] = [d for d in dirs if d not in self.EXCLUDE_DIRS]
105126
for file in files:
106127
ext = os.path.splitext(file)[1]
107128
if ext in self.SUPPORTED_EXTENSIONS:
108-
yield Path(root) / file
129+
candidate = Path(root) / file
130+
file_key = candidate.as_posix()
131+
if file_key not in seen:
132+
seen.add(file_key)
133+
yield candidate
109134

110135
def _analyze_file(self, file_path: Path):
111136
try:
@@ -164,15 +189,26 @@ def _get_maturity_level(self, score: float) -> str:
164189
# --- Dispatcher ---
165190
class CodeMaturityDispatcher:
166191
"""Entscheidet, welche Dateien/Module aktualisiert werden sollen (Policy-gesteuert)"""
167-
def __init__(self, scanner: CodeMaturityScanner, min_score: int = 80):
192+
def __init__(
193+
self,
194+
scanner: CodeMaturityScanner,
195+
min_score: int = 80,
196+
include_all_files: bool = False,
197+
target_paths: Optional[List[str]] = None,
198+
):
168199
self.scanner = scanner
169200
self.min_score = min_score
201+
self.include_all_files = include_all_files
202+
self.target_paths = target_paths or []
170203

171204
def dispatch(self, repo_root: str = "."):
172-
_debug_log('dispatcher', f'Start dispatch with min_score={self.min_score}')
173-
scan_results = self.scanner.scan(repo_root)
174-
# Policy: Nur Dateien mit Score < min_score (nicht production-ready)
175-
to_update = [r for r in scan_results if r['score'] < self.min_score]
205+
_debug_log('dispatcher', f'Start dispatch with min_score={self.min_score}, include_all_files={self.include_all_files}')
206+
scan_results = self.scanner.scan(repo_root, target_paths=self.target_paths)
207+
if self.include_all_files:
208+
to_update = scan_results
209+
else:
210+
# Policy: Nur Dateien mit Score < min_score (nicht production-ready)
211+
to_update = [r for r in scan_results if r['score'] < self.min_score]
176212
_debug_log('dispatcher', f'Selected {len(to_update)} files for header updates out of {len(scan_results)} scanned files')
177213
return to_update
178214

@@ -188,8 +224,14 @@ class CodeMaturityUpdater:
188224
* @version {version}
189225
* @note Maturity: {level}
190226
* @note Score: {score}/100
227+
* @note Module Context: {module_context}
228+
* @note Ownership Scope: {ownership_scope}
229+
* @note Primary Symbols: {primary_symbols}
191230
* @note Gap Summary: total={gaps}; TODO={todo}, Stub={stub}, Unimpl={unimpl}, Mock={mock}, Sim={sim}, Debt={debt}, C={ext_critical}, H={ext_high}, M={ext_medium}, L={ext_low}
231+
* @note Governance: {governance_context}
232+
* @note Release Context: {release_context}
192233
* @note Status: {status}{maturity_gate}
234+
* @note Generator: .github/scripts/code_maturity_header_writer.py
193235
* @note This block is auto-generated and will be overwritten.
194236
*/"""
195237
)
@@ -204,9 +246,15 @@ class CodeMaturityUpdater:
204246
* @note Maturity: {level}
205247
* @note Score: {score}/100
206248
* @note Lines: {total_lines}
249+
* @note Module Context: {module_context}
250+
* @note Ownership Scope: {ownership_scope}
251+
* @note Primary Symbols: {primary_symbols}
207252
* @note Gap Summary: total={gaps}; TODO={todo}, Stub={stub}, Unimpl={unimpl}, Mock={mock}, Sim={sim}, Debt={debt}, C={ext_critical}, H={ext_high}, M={ext_medium}, L={ext_low}
208253
* @note PR History (last 5): {pr_info}
254+
* @note Governance: {governance_context}
255+
* @note Release Context: {release_context}
209256
* @note Status: {status}{maturity_gate}
257+
* @note Generator: .github/scripts/code_maturity_header_writer.py
210258
* @note This block is auto-generated and will be overwritten.
211259
*/"""
212260
)
@@ -285,29 +333,56 @@ def _detect_maturity_gates(self, file_path: Path, repo_root: Path) -> str:
285333
_debug_log('maturity_gate_detection', f'Warning: {e}')
286334
return ''
287335

288-
def update(self, repo_root: str = "."):
289-
336+
def update(
337+
self,
338+
repo_root: str = ".",
339+
no_headers: bool = False,
340+
report_path: Optional[str] = None,
341+
validate_headers: bool = True,
342+
fail_on_validation: bool = False,
343+
):
290344
repo_root_path = Path(repo_root).resolve()
291345
_debug_log('updater', f'Load external gap details from {repo_root_path / "ai_working"}')
292346
self.external_gap_details = self._load_external_gap_details(repo_root_path)
293347
_debug_log('updater', f'Loaded external gap details for {len(self.external_gap_details)} files')
294348
to_update = self.dispatcher.dispatch(str(repo_root_path))
295-
_debug_log('updater', f'Start header updates for {len(to_update)} files')
349+
_debug_log('updater', f'Start header updates for {len(to_update)} files (no_headers={no_headers})')
296350
updated = []
297351
total = len(to_update)
298-
for index, entry in enumerate(to_update, start=1):
299-
file_path = entry['file']
300-
score = entry['score']
301-
level = entry['level']
302-
try:
303-
self._write_header(repo_root_path, Path(file_path), score, level)
304-
updated.append(file_path)
305-
if index == 1 or index % 100 == 0 or index == total:
306-
_debug_log('updater', f'Updated {index}/{total}: {Path(file_path).name}')
307-
except Exception as e:
308-
print(f"[FAIL] Header-Update für {file_path}: {e}")
352+
if not no_headers:
353+
for index, entry in enumerate(to_update, start=1):
354+
file_path = entry['file']
355+
score = entry['score']
356+
level = entry['level']
357+
try:
358+
self._write_header(repo_root_path, Path(file_path), score, level)
359+
updated.append(file_path)
360+
if index == 1 or index % 100 == 0 or index == total:
361+
_debug_log('updater', f'Updated {index}/{total}: {Path(file_path).name}')
362+
except Exception as e:
363+
print(f"[FAIL] Header-Update für {file_path}: {e}")
364+
else:
365+
_debug_log('updater', 'Header write disabled via --no-headers')
309366
print(f"[OK] {len(updated)} Header aktualisiert.")
310-
return updated
367+
validation_summary = {'checked': 0, 'failed': 0, 'failures': []}
368+
if validate_headers:
369+
validation_summary = self._validate_headers(repo_root_path, to_update)
370+
print(f"[OK] Header validation checked={validation_summary['checked']} failed={validation_summary['failed']}")
371+
372+
summary = {
373+
'scanned_candidates': total,
374+
'updated': len(updated),
375+
'no_headers': no_headers,
376+
'validation': validation_summary,
377+
'header_mode': self._resolve_header_mode(''),
378+
}
379+
if report_path:
380+
self._write_report(Path(report_path), summary)
381+
if fail_on_validation and validation_summary['failed'] > 0:
382+
summary['exit_code'] = 2
383+
else:
384+
summary['exit_code'] = 0
385+
return summary
311386

312387
def _write_header(self, repo_root: Path, file_path: Path, score: int, level: str):
313388
# Lese Originalinhalt
@@ -340,6 +415,14 @@ def _write_header(self, repo_root: Path, file_path: Path, score: int, level: str
340415
'debt': metrics['debt'],
341416
'status': status,
342417
}
418+
module_context, ownership_scope = self._derive_module_context(repo_root, file_path)
419+
template_data.update({
420+
'module_context': module_context,
421+
'ownership_scope': ownership_scope,
422+
'primary_symbols': self._extract_primary_symbols(content_ohne_header),
423+
'governance_context': 'BranchModel=develop-first; CanonicalBranches=develop,community,military',
424+
'release_context': 'GateModel=WaveA→B→C→D on develop',
425+
})
343426

344427
gap_corr = self._build_gap_correlation(repo_root, file_path, metrics['gaps'])
345428
template_data.update(gap_corr)
@@ -366,6 +449,41 @@ def _write_header(self, repo_root: Path, file_path: Path, score: int, level: str
366449
with open(file_path, 'w', encoding='utf-8') as f:
367450
f.write(new_content)
368451

452+
def _derive_module_context(self, repo_root: Path, file_path: Path) -> Tuple[str, str]:
453+
rel = file_path.relative_to(repo_root)
454+
parts = rel.parts
455+
if len(parts) < 2:
456+
return (parts[0] if parts else 'root', 'global')
457+
context = parts[0]
458+
module = parts[1]
459+
ownership = {
460+
'src': 'production-code',
461+
'include': 'public-api',
462+
'tests': 'test-suite',
463+
'benchmarks': 'benchmark-suite',
464+
}.get(context, 'global')
465+
return (f'{context}/{module}', ownership)
466+
467+
def _extract_primary_symbols(self, content: str, max_symbols: int = 6) -> str:
468+
class_pattern = re.compile(r'^\s*(?:template\s*<[^>]+>\s*)?(?:class|struct|enum(?:\s+class)?)\s+([A-Za-z_]\w*)', re.MULTILINE)
469+
function_pattern = re.compile(
470+
r'^\s*(?:inline\s+|static\s+|virtual\s+|constexpr\s+|friend\s+)*'
471+
r'(?:[\w:\<\>\,\s\*&~]+)\s+([A-Za-z_~]\w*)\s*\([^;{}]*\)\s*(?:const)?\s*(?:noexcept)?\s*(?:\{|$)',
472+
re.MULTILINE,
473+
)
474+
blocked = {'if', 'for', 'while', 'switch', 'return', 'catch'}
475+
symbols: List[str] = []
476+
for pattern in (class_pattern, function_pattern):
477+
for match in pattern.finditer(content):
478+
name = match.group(1)
479+
if not name or name in blocked:
480+
continue
481+
if name not in symbols:
482+
symbols.append(name)
483+
if len(symbols) >= max_symbols:
484+
return ', '.join(symbols)
485+
return ', '.join(symbols) if symbols else 'none-detected'
486+
369487
def _derive_score_and_level(self, content: str) -> Tuple[int, str]:
370488
content = strip_generated_header(content)
371489
scanner = self.dispatcher.scanner
@@ -608,9 +726,78 @@ def _resolve_header_mode(self, level: str) -> str:
608726
return 'extended'
609727
return self.header_mode
610728

729+
def _required_header_fragments(self, mode: str) -> List[str]:
730+
base = [
731+
'@file',
732+
'@brief',
733+
'@version',
734+
'@note Maturity:',
735+
'@note Score:',
736+
'@note Module Context:',
737+
'@note Ownership Scope:',
738+
'@note Primary Symbols:',
739+
'@note Gap Summary:',
740+
'@note Governance:',
741+
'@note Release Context:',
742+
'@note Status:',
743+
'@note Generator:',
744+
]
745+
if mode == 'extended':
746+
return ['@author', '@date', '@note Lines:', '@note PR History (last 5):'] + base
747+
return base
748+
749+
def _validate_headers(self, repo_root: Path, entries: List[Dict[str, Any]]) -> Dict[str, Any]:
750+
mode = self._resolve_header_mode('')
751+
required = self._required_header_fragments(mode)
752+
failures: List[Dict[str, Any]] = []
753+
checked = 0
754+
for entry in entries:
755+
file_path = Path(entry['file'])
756+
try:
757+
content = file_path.read_text(encoding='utf-8', errors='ignore')
758+
except Exception:
759+
failures.append({'file': file_path.as_posix(), 'missing': ['<read_failed>']})
760+
continue
761+
match = self._RE_EXISTING_HEADER.match(content)
762+
checked += 1
763+
if not match:
764+
failures.append({'file': file_path.relative_to(repo_root).as_posix(), 'missing': ['<header_block_missing>']})
765+
continue
766+
header = match.group(0)
767+
missing = [fragment for fragment in required if fragment not in header]
768+
if missing:
769+
failures.append({'file': file_path.relative_to(repo_root).as_posix(), 'missing': missing})
770+
return {'checked': checked, 'failed': len(failures), 'failures': failures}
771+
772+
def _write_report(self, report_path: Path, summary: Dict[str, Any]) -> None:
773+
report_path.parent.mkdir(parents=True, exist_ok=True)
774+
now = datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')
775+
validation = summary.get('validation', {})
776+
lines = [
777+
'# Code Maturity Header Report',
778+
'',
779+
f'- Generated: {now}',
780+
f'- Header mode: {summary.get("header_mode", "unknown")}',
781+
f'- Scan candidates: {summary.get("scanned_candidates", 0)}',
782+
f'- Headers updated: {summary.get("updated", 0)}',
783+
f'- Header write mode: {"check-only" if summary.get("no_headers") else "rewrite"}',
784+
f'- Validation checked: {validation.get("checked", 0)}',
785+
f'- Validation failed: {validation.get("failed", 0)}',
786+
'',
787+
]
788+
failures = validation.get('failures', [])
789+
if failures:
790+
lines.extend(['## Missing required header fragments', ''])
791+
for item in failures[:200]:
792+
missing = ', '.join(item.get('missing', []))
793+
lines.append(f"- `{item.get('file', '<unknown>')}` → {missing}")
794+
else:
795+
lines.append('All validated files contain required header fragments.')
796+
report_path.write_text('\n'.join(lines).rstrip() + '\n', encoding='utf-8')
797+
611798

612799
def main():
613-
parser = argparse.ArgumentParser(description='Write/update compact code maturity headers.')
800+
parser = argparse.ArgumentParser(description='Write/update canonical code maturity Doxygen headers.')
614801
parser.add_argument('--root', default='.', help='Repository root path')
615802
parser.add_argument('--min-score', type=int, default=80, help='Only files with score < min-score are updated')
616803
parser.add_argument(
@@ -619,13 +806,31 @@ def main():
619806
default='auto',
620807
help='Header detail level (auto=extended until production-ready, then lean)',
621808
)
809+
parser.add_argument('--target-paths', default='', help='Comma-separated list of paths to scan (files or directories)')
810+
parser.add_argument('--include-all-files', action='store_true', help='Update all scanned files regardless of score')
811+
parser.add_argument('--no-headers', action='store_true', help='Do not rewrite headers; run scan/validation/report only')
812+
parser.add_argument('--report-path', default='', help='Optional markdown report output path')
813+
parser.add_argument('--no-validate-headers', action='store_true', help='Disable required-header validation')
814+
parser.add_argument('--fail-on-validation', action='store_true', help='Return non-zero exit code if validation fails')
622815
args = parser.parse_args()
623816

817+
target_paths = [p.strip() for p in args.target_paths.split(',') if p.strip()]
624818
scanner = CodeMaturityScanner()
625-
dispatcher = CodeMaturityDispatcher(scanner, min_score=args.min_score)
819+
dispatcher = CodeMaturityDispatcher(
820+
scanner,
821+
min_score=args.min_score,
822+
include_all_files=args.include_all_files,
823+
target_paths=target_paths,
824+
)
626825
updater = CodeMaturityUpdater(dispatcher, header_mode=args.header_mode)
627-
updater.update(args.root)
628-
return 0
826+
summary = updater.update(
827+
args.root,
828+
no_headers=args.no_headers,
829+
report_path=args.report_path or None,
830+
validate_headers=not args.no_validate_headers,
831+
fail_on_validation=args.fail_on_validation,
832+
)
833+
return int(summary.get('exit_code', 0))
629834

630835

631836
if __name__ == '__main__':

0 commit comments

Comments
 (0)