From 4bf53eae8392f45c15bfc83cb52d1fee36c80004 Mon Sep 17 00:00:00 2001 From: casianaoprut Date: Thu, 2 Apr 2026 14:53:33 +0300 Subject: [PATCH 1/4] added voyager summary flow --- .github/workflows/release.yml | 7 +- AGENTS.md | 167 ++++++++ README.md | 17 + codeframe-summary.py | 59 +++ instrument.v2.yml | 35 ++ summary_extract.py | 765 ++++++++++++++++++++++++++++++++++ summary_render.py | 271 ++++++++++++ templates/summary.html | 159 +++++++ 8 files changed, 1479 insertions(+), 1 deletion(-) create mode 100644 AGENTS.md create mode 100644 codeframe-summary.py create mode 100644 instrument.v2.yml create mode 100644 summary_extract.py create mode 100644 summary_render.py create mode 100644 templates/summary.html diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6b93e64..e62e810 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,13 +45,18 @@ jobs: id: assets run: | set -euo pipefail - mkdir -p codeframe + mkdir -p codeframe/templates # Use constant fat jar name produced by Shadow config JAR_PATH="build/libs/codeframe.jar" cp "$JAR_PATH" codeframe/codeframe.jar cp instrument.yml codeframe/instrument.yml + cp instrument.v2.yml codeframe/instrument.v2.yml cp .ignore codeframe/.ignore cp codeframe-config.yml codeframe/codeframe-config.yml + cp codeframe-summary.py codeframe/codeframe-summary.py + cp summary_extract.py codeframe/summary_extract.py + cp summary_render.py codeframe/summary_render.py + cp templates/summary.html codeframe/templates/summary.html # Voyager archive name ARCHIVE_NAME="codeframe-${{ steps.tag.outputs.suffix }}" echo "archive_name=$ARCHIVE_NAME" >> $GITHUB_OUTPUT diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..02df53b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,167 @@ +# AGENTS.md + +Agent playbook for working in `codeframe`. + +## 1) Project at a Glance + +- Language/runtime: Java 17, Gradle 8.x. +- Main entry point: `org.dxworks.codeframe.App`. +- Packaging: Shadow JAR output is `build/libs/codeframe.jar`. +- Core purpose: parse multi-language source files and emit JSONL analysis. +- Parser stack: + - Tree-sitter: Java, JavaScript, TypeScript, Python, C#, PHP, Ruby, Rust. + - Hybrid: SQL (JSqlParser + ANTLR), COBOL (ANTLR), Markdown (commonmark). + +## 2) Environment Requirements + +- Required JDK: 17+. +- If Gradle fails with "build uses a Java 11 JVM", set `JAVA_HOME` to JDK 17. +- Gradle wrapper is included; prefer wrapper over system Gradle. +- Cross-platform command variants: + - Unix/macOS: `./gradlew ...` + - Windows: `./gradlew.bat ...` + +## 3) Build, Test, and Run Commands + +### Build + +- Full build (includes tests + shadow jar): + - `./gradlew build` +- Clean build: + - `./gradlew clean build` +- Build shadow jar directly: + - `./gradlew shadowJar` + +### Test + +- Run all tests: + - `./gradlew test` +- Run a single test class: + - `./gradlew test --tests "*JavaAnalyzeApprovalTest"` +- Run a single test method (most common agent workflow): + - `./gradlew test --tests "*JavaAnalyzeApprovalTest.analyze_Java_GenericsSample"` +- Run a package slice: + - `./gradlew test --tests "org.dxworks.codeframe.analyzer.sql.*"` +- Useful debugging flags: + - `./gradlew test --stacktrace --info` + +### Lint / Static Checks + +- There is no dedicated Checkstyle/SpotBugs/Spotless task configured. +- Use `./gradlew check` for standard Gradle verification lifecycle. +- In this repo, test suites are the primary quality gate. + +### Run Application + +- Run via Gradle: + - `./gradlew run --args=" "` +- Run packaged jar: + - `java -jar build/libs/codeframe.jar ` +- Typical local example: + - `./gradlew run --args="src codeframe-out/analysis.jsonl"` + +### Grammar Generation (ANTLR) + +- Generate SQL + COBOL grammars: + - `./gradlew generateAllGrammarSource` +- `compileJava` and `test` already depend on grammar generation. + +## 4) CI and Release Behavior + +- CI workflow (`.github/workflows/build-and-test.yml`) runs: + - `./gradlew --no-daemon --stacktrace test` +- Release workflow (`.github/workflows/release.yml`) runs: + - `./gradlew --no-daemon clean shadowJar` +- Release artifact zip contains: + - `codeframe.jar`, `instrument.yml`, `.ignore`, `codeframe-config.yml`. + +## 5) Test Conventions (ApprovalTests) + +- Framework: JUnit Jupiter + ApprovalTests. +- Approval snapshots live beside tests as `*.approved.txt`. +- On failure, inspect `*.received.txt` and confirm expected behavior. +- To accept intentional output changes, replace approved files with received files. +- Commit code and corresponding approved-output updates together. + +## 6) Code Style and Structure + +### Formatting + +- Use 4-space indentation, UTF-8 source files. +- Keep methods focused and small where practical. +- Prefer early returns for guard clauses. +- Preserve deterministic output ordering when possible. + +### Imports + +- Prefer explicit imports over wildcard imports. +- Keep `java.*` imports grouped and readable. +- Use static imports only for well-scoped utility usage. + +### Types and Data Modeling + +- Use concrete model classes under `org.dxworks.codeframe.model`. +- Public model fields are used intentionally in this project; follow existing model style. +- Use `Optional` only where APIs already expose optionality (e.g., language detection). +- Favor immutable/unmodifiable collections for configuration and registries. + +### Naming + +- Classes/interfaces/enums: PascalCase. +- Methods/fields/local vars: camelCase. +- Constants: UPPER_SNAKE_CASE. +- Tests use descriptive snake-like method names with scenario context + (example: `analyze_Java_GenericsSample`). + +### Analyzer-Specific Design + +- Implement new analyzers via `LanguageAnalyzer` and register in `LanguageRegistry`. +- Reuse `TreeSitterHelper` utilities rather than duplicating traversal logic. +- Prefer AST-driven extraction over regex. +- Keep extraction syntactic/factual; avoid semantic inference. +- Ensure stable sorting/deduplication for method call outputs. + +### Error Handling + +- Fail fast for invalid startup conditions (e.g., missing input path). +- During file analysis, catch exceptions per file and emit error JSONL records. +- Prefer partial results over hard failure when parsing problematic inputs. +- Do not add side effects (writing extra files, network calls) inside analyzers. + +### Backward Compatibility and Complexity + +- Do not add complexity solely for backward compatibility unless requested. +- Do not ship hacks; understand grammar/tree shape and solve at parser level. +- Use as little regex as practical; regex is a fallback, not default strategy. + +## 7) Extraction Contract (Must Follow) + +- Extract facts only. +- Keep output deterministic. +- Avoid analyzer side effects. +- Return partial results on parse errors when feasible. +- Keep analyzers simple and fast; enrichment belongs elsewhere. + +## 8) Repository-Specific Agent Rules + +- Relevant guidance source files: + - `docs/CONTRIBUTING.md` + - `docs/ARCHITECTURE.md` + - `README.md` + - `docs/AGENTS.md` (legacy short guidance) +- Cursor rules: + - No `.cursor/rules/` directory found. + - No `.cursorrules` file found. +- Copilot rules: + - No `.github/copilot-instructions.md` file found. + +## 9) Practical Agent Workflow + +- Before changing analyzers: + - Read matching analyzer tests and approved snapshots first. +- After changes: + - Run targeted single-test command. + - Run broader language test class. + - Run full `./gradlew test` if scope is cross-cutting. +- If output shape changes: + - Update approved files intentionally and review diffs carefully. diff --git a/README.md b/README.md index 7fbbb48..4e65f2e 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,23 @@ docker run --rm -it -v "$PWD:/workspace" -v "/path/to/code:/src:ro" -w /workspac The analysis results are written to the path you pass as the second argument (e.g., `/workspace/.out/analysis.jsonl`) in **JSONL format** (JSON Lines - one JSON object per line). Parent directories for the output file are created automatically, and `.out/` is gitignored by default. +### Summary artifacts + +CodeFrame includes a Voyager summary generator that parses `results/*.jsonl` and emits: + +- `results/summary.md` +- `results/summary.html` + +Run it with: + +```bash +# Unix/macOS +python3 codeframe-summary.py results + +# Windows +py -3 codeframe-summary.py results +``` + ### Ignore patterns (.ignore) - Location: project root `.ignore` (included in releases). diff --git a/codeframe-summary.py b/codeframe-summary.py new file mode 100644 index 0000000..88daef7 --- /dev/null +++ b/codeframe-summary.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +from pathlib import Path + +from summary_extract import extract_codeframe_summary +from summary_render import render_summary + + +def build_missing_payload() -> dict[str, object]: + return { + 'tool': 'codeframe', + 'status': 'missing', + 'metadata': {}, + 'markdown': '\n'.join([ + '## CodeFrame', + '', + '- Summary input is missing', + ]), + 'templateModel': { + 'isMissing': True, + }, + } + + +def main() -> int: + parser = argparse.ArgumentParser( + prog='codeframe-summary.py', + description='Generates codeframe summary artifacts for Voyager', + ) + parser.add_argument('results_directory', nargs='?', default='results') + args = parser.parse_args() + + target_directory = Path(args.results_directory).resolve() + + try: + jsonl_files = list(target_directory.glob('*.jsonl')) + if len(jsonl_files) == 0: + print( + "summary input missing for codeframe: expected '*.jsonl' files " + f"in '{target_directory}'; generating missing summary artifacts" + ) + payload = build_missing_payload() + else: + payload = extract_codeframe_summary(target_directory) + rendered = render_summary(target_directory, payload) + + print(f"Generated summary markdown at {rendered['summaryMdPath']}") + print(f"Generated summary html at {rendered['summaryHtmlPath']}") + return 0 + except Exception as error: + print(f"summary generation failed for '{target_directory}': {error}") + return 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/instrument.v2.yml b/instrument.v2.yml new file mode 100644 index 0000000..8ee5bd3 --- /dev/null +++ b/instrument.v2.yml @@ -0,0 +1,35 @@ +name: codeframe +id: codeframe +version: 1.0.0 + +actions: + start: + commands: + run-codeframe: + id: run-codeframe + dir: ${instrumentPath} + command: >- + java -jar codeframe.jar "${repo}" "${instrumentPath}/results/${repoName}.jsonl" + + summary: + md-file: results/summary.md + html-file: results/summary.html + category: Structural Relationship + commands: + generate-summary: + id: generate-summary + dir: ${instrumentPath} + command: + windows: py -3 codeframe-summary.py "${instrumentPath}/results" + unix: python3 codeframe-summary.py "${instrumentPath}/results" + + clean: + with: + locations: + - source: ${instrumentDir}/results + + pack: + with: + locations: + - source: ${instrumentDir}/results + destination: /results diff --git a/summary_extract.py b/summary_extract.py new file mode 100644 index 0000000..3c40ab1 --- /dev/null +++ b/summary_extract.py @@ -0,0 +1,765 @@ +from __future__ import annotations + +import json +from datetime import datetime +from pathlib import Path +from typing import Any + + +def extract_codeframe_summary(results_directory: str | Path) -> dict[str, Any]: + target = Path(results_directory) + + try: + entries = list(target.iterdir()) + except Exception: + return _create_summary_payload( + jsonl_files=[], + files_total=0, + languages={}, + type_kinds={}, + metrics=_empty_metrics(), + run_expected_total=0, + run_files_analyzed=0, + run_files_with_errors=0, + run_duration_seconds=0, + has_data_quality_issues=True, + ) + + jsonl_files = sorted( + [entry for entry in entries if entry.is_file() and entry.suffix.lower() == '.jsonl'], + key=lambda value: value.name, + ) + + files_total = 0 + languages: dict[str, int] = {} + type_kinds: dict[str, int] = {} + metrics = _empty_metrics() + + run_expected_total = 0 + run_files_analyzed = 0 + run_files_with_errors = 0 + run_duration_seconds = 0 + + invalid_lines = 0 + parse_failures = 0 + + sql_metrics = { + 'files': 0, + 'createTables': 0, + 'alterTables': 0, + 'createViews': 0, + 'createIndexes': 0, + 'createProcedures': 0, + 'createFunctions': 0, + 'createTriggers': 0, + 'dropOperations': 0, + } + cobol_metrics = { + 'files': 0, + 'sections': 0, + 'paragraphs': 0, + 'dataItems': 0, + 'copyStatements': 0, + 'fileDefinitions': 0, + } + markdown_metrics = { + 'files': 0, + 'sections': 0, + 'elements': 0, + } + + language_metrics: dict[str, dict[str, int]] = {} + + for jsonl_file in jsonl_files: + file_parsed = _parse_jsonl_file(jsonl_file) + + files_total += file_parsed['filesTotal'] + _merge_counter(languages, file_parsed['languages']) + _merge_counter(type_kinds, file_parsed['typeKinds']) + _merge_counter(metrics, file_parsed['metrics']) + + run_expected_total += file_parsed['runExpectedTotal'] + run_files_analyzed += file_parsed['runFilesAnalyzed'] + run_files_with_errors += file_parsed['runFilesWithErrors'] + run_duration_seconds += file_parsed['runDurationSeconds'] + + invalid_lines += file_parsed['invalidLines'] + parse_failures += file_parsed['parseFailures'] + + _merge_counter(sql_metrics, file_parsed['sqlMetrics']) + _merge_counter(cobol_metrics, file_parsed['cobolMetrics']) + _merge_counter(markdown_metrics, file_parsed['markdownMetrics']) + _merge_nested_language_metrics(language_metrics, file_parsed['languageMetrics']) + + coverage_percent = _percent(run_files_analyzed, run_expected_total) + has_data_quality_issues = ( + parse_failures > 0 + or invalid_lines > 0 + or files_total == 0 + or run_files_with_errors > 0 + or _is_coverage_incomplete(run_expected_total, run_files_analyzed) + ) + + status = _resolve_status( + jsonl_count=len(jsonl_files), + files_total=files_total, + has_data_quality_issues=has_data_quality_issues, + ) + + return _create_summary_payload( + jsonl_files=jsonl_files, + files_total=files_total, + languages=languages, + type_kinds=type_kinds, + metrics=metrics, + run_expected_total=run_expected_total, + run_files_analyzed=run_files_analyzed, + run_files_with_errors=run_files_with_errors, + run_duration_seconds=run_duration_seconds, + invalid_lines=invalid_lines, + parse_failures=parse_failures, + coverage_percent=coverage_percent, + status=status, + sql_metrics=sql_metrics, + cobol_metrics=cobol_metrics, + markdown_metrics=markdown_metrics, + language_metrics=language_metrics, + has_data_quality_issues=has_data_quality_issues, + ) + + +def _parse_jsonl_file(file_path: Path) -> dict[str, Any]: + files_total = 0 + languages: dict[str, int] = {} + type_kinds: dict[str, int] = {} + metrics = _empty_metrics() + + run_expected_total = 0 + run_files_analyzed = 0 + run_files_with_errors = 0 + run_duration_seconds = 0 + + invalid_lines = 0 + parse_failures = 0 + + sql_metrics = { + 'files': 0, + 'createTables': 0, + 'alterTables': 0, + 'createViews': 0, + 'createIndexes': 0, + 'createProcedures': 0, + 'createFunctions': 0, + 'createTriggers': 0, + 'dropOperations': 0, + } + cobol_metrics = { + 'files': 0, + 'sections': 0, + 'paragraphs': 0, + 'dataItems': 0, + 'copyStatements': 0, + 'fileDefinitions': 0, + } + markdown_metrics = { + 'files': 0, + 'sections': 0, + 'elements': 0, + } + + language_metrics: dict[str, dict[str, int]] = {} + + try: + for raw_line in file_path.read_text(encoding='utf-8', errors='replace').splitlines(): + line = raw_line.strip() + if not line: + continue + + try: + record = json.loads(line) + except Exception: + invalid_lines += 1 + continue + + if not isinstance(record, dict): + invalid_lines += 1 + continue + + record_kind = str(record.get('kind') or '') + if record_kind == 'run': + run_expected_total += _to_int(record.get('total_files')) + continue + + if record_kind == 'done': + run_files_analyzed += _to_int(record.get('files_analyzed')) + run_files_with_errors += _to_int(record.get('files_with_errors')) + run_duration_seconds += _to_int(record.get('duration_seconds')) + continue + + if record_kind == 'error': + run_files_with_errors += 1 + continue + + if 'language' not in record: + invalid_lines += 1 + continue + + files_total += 1 + language = str(record.get('language') or 'unknown').strip().lower() or 'unknown' + languages[language] = languages.get(language, 0) + 1 + + language_entry = language_metrics.setdefault( + language, + { + 'files': 0, + 'types': 0, + 'methods': 0, + 'fields': 0, + 'relationships': 0, + }, + ) + language_entry['files'] += 1 + + file_imports = _safe_list(record.get('imports')) + metrics['importsTotal'] += len(file_imports) + + top_level_fields = _safe_list(record.get('fields')) + top_level_methods = _safe_list(record.get('methods')) + top_level_calls = _safe_list(record.get('methodCalls')) + metrics['topLevelFieldsTotal'] += len(top_level_fields) + metrics['topLevelMethodsTotal'] += len(top_level_methods) + metrics['methodCallEdgesTotal'] += len(top_level_calls) + metrics['methodCallCountTotal'] += _sum_call_count(top_level_calls) + + language_entry['fields'] += len(top_level_fields) + language_entry['methods'] += len(top_level_methods) + language_entry['relationships'] += len(top_level_calls) + + for method in top_level_methods: + if not isinstance(method, dict): + continue + parameters = _safe_list(method.get('parameters')) + local_variables = _safe_list(method.get('localVariables')) + method_calls = _safe_list(method.get('methodCalls')) + metrics['methodParametersTotal'] += len(parameters) + metrics['methodLocalVariablesTotal'] += len(local_variables) + metrics['methodCallEdgesTotal'] += len(method_calls) + metrics['methodCallCountTotal'] += _sum_call_count(method_calls) + language_entry['relationships'] += len(method_calls) + + type_scan = _scan_types( + _safe_list(record.get('types')), + type_kinds, + language_entry, + ) + _merge_counter(metrics, type_scan) + + if language == 'sql': + sql_metrics['files'] += 1 + sql_metrics['createTables'] += len(_safe_list(record.get('createTables'))) + sql_metrics['alterTables'] += len(_safe_list(record.get('alterTables'))) + sql_metrics['createViews'] += len(_safe_list(record.get('createViews'))) + sql_metrics['createIndexes'] += len(_safe_list(record.get('createIndexes'))) + sql_metrics['createProcedures'] += len(_safe_list(record.get('createProcedures'))) + sql_metrics['createFunctions'] += len(_safe_list(record.get('createFunctions'))) + sql_metrics['createTriggers'] += len(_safe_list(record.get('createTriggers'))) + sql_metrics['dropOperations'] += len(_safe_list(record.get('dropOperations'))) + + if language == 'cobol': + cobol_metrics['files'] += 1 + cobol_metrics['sections'] += len(_safe_list(record.get('sections'))) + cobol_metrics['paragraphs'] += len(_safe_list(record.get('paragraphs'))) + cobol_metrics['dataItems'] += len(_safe_list(record.get('dataItems'))) + cobol_metrics['copyStatements'] += len(_safe_list(record.get('copyStatements'))) + cobol_metrics['fileDefinitions'] += len(_safe_list(record.get('fileDefinitions'))) + + if language == 'markdown': + markdown_metrics['files'] += 1 + sections = _safe_list(record.get('sections')) + markdown_metrics['sections'] += _count_markdown_sections(sections) + markdown_metrics['elements'] += _count_markdown_elements(sections) + except Exception: + parse_failures += 1 + + return { + 'filesTotal': files_total, + 'languages': languages, + 'typeKinds': type_kinds, + 'metrics': metrics, + 'runExpectedTotal': run_expected_total, + 'runFilesAnalyzed': run_files_analyzed, + 'runFilesWithErrors': run_files_with_errors, + 'runDurationSeconds': run_duration_seconds, + 'invalidLines': invalid_lines, + 'parseFailures': parse_failures, + 'sqlMetrics': sql_metrics, + 'cobolMetrics': cobol_metrics, + 'markdownMetrics': markdown_metrics, + 'languageMetrics': language_metrics, + } + + +def _scan_types(types: list[Any], type_kinds: dict[str, int], language_entry: dict[str, int]) -> dict[str, int]: + metrics = _empty_metrics() + + for type_info in types: + if not isinstance(type_info, dict): + continue + + kind = str(type_info.get('kind') or 'other').strip().lower() or 'other' + type_kinds[kind] = type_kinds.get(kind, 0) + 1 + + metrics['typesTotal'] += 1 + language_entry['types'] += 1 + + if _has_value(type_info.get('extendsType')): + metrics['extendsTotal'] += 1 + language_entry['relationships'] += 1 + + implemented = _safe_list(type_info.get('implementsInterfaces')) + mixins = _safe_list(type_info.get('mixins')) + metrics['implementsTotal'] += len(implemented) + metrics['mixinsTotal'] += len(mixins) + language_entry['relationships'] += len(implemented) + len(mixins) + + fields = _safe_list(type_info.get('fields')) + properties = _safe_list(type_info.get('properties')) + methods = _safe_list(type_info.get('methods')) + nested_types = _safe_list(type_info.get('types')) + + metrics['typeFieldsTotal'] += len(fields) + metrics['typePropertiesTotal'] += len(properties) + metrics['typeMethodsTotal'] += len(methods) + + language_entry['fields'] += len(fields) + len(properties) + language_entry['methods'] += len(methods) + + for method in methods: + if not isinstance(method, dict): + continue + parameters = _safe_list(method.get('parameters')) + local_variables = _safe_list(method.get('localVariables')) + method_calls = _safe_list(method.get('methodCalls')) + metrics['methodParametersTotal'] += len(parameters) + metrics['methodLocalVariablesTotal'] += len(local_variables) + metrics['methodCallEdgesTotal'] += len(method_calls) + metrics['methodCallCountTotal'] += _sum_call_count(method_calls) + language_entry['relationships'] += len(method_calls) + + nested_metrics = _scan_types(nested_types, type_kinds, language_entry) + _merge_counter(metrics, nested_metrics) + + return metrics + + +def _count_markdown_sections(sections: list[Any]) -> int: + total = 0 + for section in sections: + if not isinstance(section, dict): + continue + total += 1 + total += _count_markdown_sections(_safe_list(section.get('subsections'))) + return total + + +def _count_markdown_elements(sections: list[Any]) -> int: + total = 0 + for section in sections: + if not isinstance(section, dict): + continue + total += len(_safe_list(section.get('elements'))) + total += _count_markdown_elements(_safe_list(section.get('subsections'))) + return total + + +def _create_summary_payload( + jsonl_files: list[Path], + files_total: int, + languages: dict[str, int], + type_kinds: dict[str, int], + metrics: dict[str, int], + run_expected_total: int, + run_files_analyzed: int, + run_files_with_errors: int, + run_duration_seconds: int, + status: str = 'failed', + invalid_lines: int = 0, + parse_failures: int = 0, + coverage_percent: str = '0', + sql_metrics: dict[str, int] | None = None, + cobol_metrics: dict[str, int] | None = None, + markdown_metrics: dict[str, int] | None = None, + language_metrics: dict[str, dict[str, int]] | None = None, + has_data_quality_issues: bool = True, +) -> dict[str, Any]: + generated_at = _iso_now() + + sql_metrics = sql_metrics or { + 'files': 0, + 'createTables': 0, + 'alterTables': 0, + 'createViews': 0, + 'createIndexes': 0, + 'createProcedures': 0, + 'createFunctions': 0, + 'createTriggers': 0, + 'dropOperations': 0, + } + cobol_metrics = cobol_metrics or { + 'files': 0, + 'sections': 0, + 'paragraphs': 0, + 'dataItems': 0, + 'copyStatements': 0, + 'fileDefinitions': 0, + } + markdown_metrics = markdown_metrics or { + 'files': 0, + 'sections': 0, + 'elements': 0, + } + language_metrics = language_metrics or {} + + language_rows = _build_language_rows(files_total, language_metrics) + type_kind_rows = _build_type_kind_rows(type_kinds) + + metadata: dict[str, Any] = { + 'jsonl.files': len(jsonl_files), + 'files.total': files_total, + 'run.total.files': run_expected_total, + 'run.files.analyzed': run_files_analyzed, + 'run.files.with.errors': run_files_with_errors, + 'run.duration.seconds': run_duration_seconds, + 'run.coverage.percent': coverage_percent, + 'languages.count': len(languages), + 'types.total': metrics['typesTotal'], + 'relationships.extends.total': metrics['extendsTotal'], + 'relationships.implements.total': metrics['implementsTotal'], + 'relationships.mixins.total': metrics['mixinsTotal'], + 'imports.total': metrics['importsTotal'], + 'method.calls.edges.total': metrics['methodCallEdgesTotal'], + 'method.calls.total': metrics['methodCallCountTotal'], + 'data.invalid.lines': invalid_lines, + 'data.parse.failures': parse_failures, + 'generated.at': generated_at, + } + + for language, count in sorted(languages.items()): + metadata[f'languages.{language}.files'] = count + + for kind, count in sorted(type_kinds.items()): + metadata[f'types.kind.{kind}'] = count + + markdown_lines: list[str] = [ + '## CodeFrame', + '', + f'- JSONL files: {_format_int(len(jsonl_files))}', + f'- Files analyzed: {_format_int(files_total)}', + f'- Coverage: {_format_int(run_files_analyzed)}/{_format_int(run_expected_total)} ({coverage_percent}%)', + f'- Files with errors: {_format_int(run_files_with_errors)}', + f'- Duration: {_format_int(run_duration_seconds)} s', + f'- Type declarations: {_format_int(metrics["typesTotal"])}', + f'- Method call edges: {_format_int(metrics["methodCallEdgesTotal"])}', + f'- Relationship links (extends/implements/mixins): ' + f"{_format_int(metrics['extendsTotal'] + metrics['implementsTotal'] + metrics['mixinsTotal'])}", + '', + '### Structural Metrics by Language', + '', + '| Language | Files | Share | Types | Methods | Fields/Props | Relationship Links |', + '| --- | ---: | ---: | ---: | ---: | ---: | ---: |', + ] + + for row in language_rows: + markdown_lines.append( + f"| {row['language']} | {row['filesFormatted']} | {row['share']} | {row['typesFormatted']} " + f"| {row['methodsFormatted']} | {row['fieldsFormatted']} | {row['relationshipsFormatted']} |" + ) + + markdown_lines.extend( + [ + '', + '### Type Kind Breakdown', + '', + '| Kind | Count |', + '| --- | ---: |', + ] + ) + + for row in type_kind_rows: + markdown_lines.append(f"| {row['kind']} | {row['countFormatted']} |") + + if sql_metrics['files'] > 0: + markdown_lines.extend( + [ + '', + '### SQL Structural Footprint', + '', + f"- SQL files: {_format_int(sql_metrics['files'])}", + f"- CREATE TABLE: {_format_int(sql_metrics['createTables'])}", + f"- ALTER TABLE: {_format_int(sql_metrics['alterTables'])}", + f"- CREATE VIEW: {_format_int(sql_metrics['createViews'])}", + f"- CREATE INDEX: {_format_int(sql_metrics['createIndexes'])}", + f"- Routines (procedures/functions/triggers): " + f"{_format_int(sql_metrics['createProcedures'] + sql_metrics['createFunctions'] + sql_metrics['createTriggers'])}", + f"- DROP operations: {_format_int(sql_metrics['dropOperations'])}", + ] + ) + + if cobol_metrics['files'] > 0: + markdown_lines.extend( + [ + '', + '### COBOL Structural Footprint', + '', + f"- COBOL files: {_format_int(cobol_metrics['files'])}", + f"- Sections: {_format_int(cobol_metrics['sections'])}", + f"- Paragraphs: {_format_int(cobol_metrics['paragraphs'])}", + f"- Data items: {_format_int(cobol_metrics['dataItems'])}", + f"- Copy statements: {_format_int(cobol_metrics['copyStatements'])}", + f"- File definitions: {_format_int(cobol_metrics['fileDefinitions'])}", + ] + ) + + if markdown_metrics['files'] > 0: + markdown_lines.extend( + [ + '', + '### Markdown Structural Footprint', + '', + f"- Markdown files: {_format_int(markdown_metrics['files'])}", + f"- Sections: {_format_int(markdown_metrics['sections'])}", + f"- Block elements: {_format_int(markdown_metrics['elements'])}", + ] + ) + + template_model = { + 'generatedAt': generated_at, + 'isDataQualityPartial': has_data_quality_issues, + 'metrics': { + 'jsonlFilesFormatted': _format_int(len(jsonl_files)), + 'filesTotalFormatted': _format_int(files_total), + 'runTotalFilesFormatted': _format_int(run_expected_total), + 'runFilesAnalyzedFormatted': _format_int(run_files_analyzed), + 'runFilesWithErrorsFormatted': _format_int(run_files_with_errors), + 'runCoveragePercent': coverage_percent, + 'runDurationSecondsFormatted': _format_int(run_duration_seconds), + 'typesTotalFormatted': _format_int(metrics['typesTotal']), + 'extendsTotalFormatted': _format_int(metrics['extendsTotal']), + 'implementsTotalFormatted': _format_int(metrics['implementsTotal']), + 'mixinsTotalFormatted': _format_int(metrics['mixinsTotal']), + 'importsTotalFormatted': _format_int(metrics['importsTotal']), + 'methodCallEdgesTotalFormatted': _format_int(metrics['methodCallEdgesTotal']), + 'methodCallCountTotalFormatted': _format_int(metrics['methodCallCountTotal']), + 'invalidLinesFormatted': _format_int(invalid_lines), + 'parseFailuresFormatted': _format_int(parse_failures), + }, + 'languageRows': language_rows, + 'typeKindRows': type_kind_rows, + 'hasSqlMetrics': sql_metrics['files'] > 0, + 'hasCobolMetrics': cobol_metrics['files'] > 0, + 'hasMarkdownMetrics': markdown_metrics['files'] > 0, + 'sqlMetrics': { + 'filesFormatted': _format_int(sql_metrics['files']), + 'createTablesFormatted': _format_int(sql_metrics['createTables']), + 'alterTablesFormatted': _format_int(sql_metrics['alterTables']), + 'createViewsFormatted': _format_int(sql_metrics['createViews']), + 'createIndexesFormatted': _format_int(sql_metrics['createIndexes']), + 'createProceduresFormatted': _format_int(sql_metrics['createProcedures']), + 'createFunctionsFormatted': _format_int(sql_metrics['createFunctions']), + 'createTriggersFormatted': _format_int(sql_metrics['createTriggers']), + 'dropOperationsFormatted': _format_int(sql_metrics['dropOperations']), + }, + 'cobolMetrics': { + 'filesFormatted': _format_int(cobol_metrics['files']), + 'sectionsFormatted': _format_int(cobol_metrics['sections']), + 'paragraphsFormatted': _format_int(cobol_metrics['paragraphs']), + 'dataItemsFormatted': _format_int(cobol_metrics['dataItems']), + 'copyStatementsFormatted': _format_int(cobol_metrics['copyStatements']), + 'fileDefinitionsFormatted': _format_int(cobol_metrics['fileDefinitions']), + }, + 'markdownMetrics': { + 'filesFormatted': _format_int(markdown_metrics['files']), + 'sectionsFormatted': _format_int(markdown_metrics['sections']), + 'elementsFormatted': _format_int(markdown_metrics['elements']), + }, + } + + return { + 'tool': 'codeframe', + 'status': status, + 'metadata': metadata, + 'markdown': '\n'.join(markdown_lines), + 'templateModel': template_model, + } + + +def _build_language_rows( + files_total: int, + language_metrics: dict[str, dict[str, int]], +) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + + for language, values in language_metrics.items(): + files = values.get('files', 0) + types = values.get('types', 0) + methods = values.get('methods', 0) + fields = values.get('fields', 0) + relationships = values.get('relationships', 0) + + rows.append( + { + 'language': language, + 'files': files, + 'types': types, + 'methods': methods, + 'fields': fields, + 'relationships': relationships, + 'filesFormatted': _format_int(files), + 'typesFormatted': _format_int(types), + 'methodsFormatted': _format_int(methods), + 'fieldsFormatted': _format_int(fields), + 'relationshipsFormatted': _format_int(relationships), + 'share': f"{_percent(files, files_total)}%", + } + ) + + rows.sort(key=lambda row: (-row['files'], -row['types'], row['language'])) + return rows + + +def _build_type_kind_rows(type_kinds: dict[str, int]) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + + for kind, count in type_kinds.items(): + rows.append( + { + 'kind': kind, + 'count': count, + 'countFormatted': _format_int(count), + } + ) + + rows.sort(key=lambda row: (-row['count'], row['kind'])) + return rows + + +def _resolve_status(jsonl_count: int, files_total: int, has_data_quality_issues: bool) -> str: + if jsonl_count == 0 or files_total == 0: + return 'failed' + if has_data_quality_issues: + return 'partial' + return 'success' + + +def _empty_metrics() -> dict[str, int]: + return { + 'typesTotal': 0, + 'typeMethodsTotal': 0, + 'typeFieldsTotal': 0, + 'typePropertiesTotal': 0, + 'topLevelMethodsTotal': 0, + 'topLevelFieldsTotal': 0, + 'extendsTotal': 0, + 'implementsTotal': 0, + 'mixinsTotal': 0, + 'importsTotal': 0, + 'methodCallEdgesTotal': 0, + 'methodCallCountTotal': 0, + 'methodParametersTotal': 0, + 'methodLocalVariablesTotal': 0, + } + + +def _safe_list(value: Any) -> list[Any]: + if isinstance(value, list): + return value + return [] + + +def _sum_call_count(calls: list[Any]) -> int: + total = 0 + for call in calls: + if not isinstance(call, dict): + total += 1 + continue + count = call.get('callCount') + if isinstance(count, int): + total += max(count, 0) + else: + total += 1 + return total + + +def _merge_counter(target: dict[str, int], source: dict[str, int]) -> None: + for key, value in source.items(): + target[key] = target.get(key, 0) + value + + +def _merge_nested_language_metrics( + target: dict[str, dict[str, int]], + source: dict[str, dict[str, int]], +) -> None: + for language, metrics in source.items(): + if language not in target: + target[language] = { + 'files': 0, + 'types': 0, + 'methods': 0, + 'fields': 0, + 'relationships': 0, + } + for metric, value in metrics.items(): + target[language][metric] = target[language].get(metric, 0) + value + + +def _to_int(value: Any) -> int: + try: + return int(value) + except Exception: + return 0 + + +def _has_value(value: Any) -> bool: + if value is None: + return False + if isinstance(value, str): + return value.strip() != '' + return True + + +def _is_coverage_incomplete(expected: int, analyzed: int) -> bool: + if expected <= 0: + return False + return analyzed < expected + + +def _percent(value: int, total: int) -> str: + if total <= 0: + return '0' + percent = (value / total) * 100 + if percent.is_integer(): + return str(int(percent)) + return f'{percent:.2f}'.rstrip('0').rstrip('.') + + +def _format_int(value: int) -> str: + return f'{value:,}' + + +def _iso_now() -> str: + local_now = datetime.now().astimezone() + return f"{local_now.strftime('%Y-%m-%d %H:%M:%S')} {_format_gmt_offset(local_now.strftime('%z'))}" + + +def _format_gmt_offset(offset: str) -> str: + if len(offset) != 5: + return 'GMT+0' + + sign = offset[0] + hours = int(offset[1:3]) + minutes = int(offset[3:5]) + + if minutes == 0: + return f'GMT{sign}{hours}' + + return f'GMT{sign}{hours}:{minutes:02d}' diff --git a/summary_render.py b/summary_render.py new file mode 100644 index 0000000..899f668 --- /dev/null +++ b/summary_render.py @@ -0,0 +1,271 @@ +from __future__ import annotations + +import html +from pathlib import Path +from typing import Any + + +DEFAULT_TEMPLATE_PATH = Path(__file__).resolve().parent / 'templates' / 'summary.html' +FALLBACK_TEMPLATE = '

{{tool}}

' + + +def render_summary( + results_directory: str | Path, + payload: dict[str, Any], + template_path: str | Path | None = None, +) -> dict[str, Any]: + target = Path(results_directory) + target.mkdir(parents=True, exist_ok=True) + + template_file = Path(template_path) if template_path else DEFAULT_TEMPLATE_PATH + try: + template = template_file.read_text(encoding='utf-8') + except Exception: + template = FALLBACK_TEMPLATE + + tool = str(payload.get('tool') or 'unknown') + status = str(payload.get('status') or 'unknown') + metadata = payload.get('metadata') or {} + markdown = str(payload.get('markdown') or '') + template_model = payload.get('templateModel') or {} + + if not isinstance(metadata, dict): + raise ValueError('summary payload metadata must be an object') + if not isinstance(template_model, dict): + raise ValueError('summary payload templateModel must be an object') + + model = dict(template_model) + model.setdefault('tool', tool) + model.setdefault('status', status) + + rendered_html = _render_template(template, model) + metadata_block = _build_metadata_block(tool, status, metadata) + + summary_md_path = target / 'summary.md' + summary_html_path = target / 'summary.html' + + summary_html_path.write_text(rendered_html, encoding='utf-8') + summary_md_path.write_text(f"{metadata_block}\n---\n{markdown}\n", encoding='utf-8') + + return { + 'status': status, + 'summaryMdPath': str(summary_md_path), + 'summaryHtmlPath': str(summary_html_path), + } + + +def _build_metadata_block(tool: str, status: str, metadata: dict[str, Any]) -> str: + lines = [ + '---', + f'tool: {tool}', + 'html-template: reference', + f'status: {status}', + ] + + for key, value in metadata.items(): + lines.append(f'{key}: {_stringify_metadata_value(value)}') + + return '\n'.join(lines) + + +def _stringify_metadata_value(value: Any) -> str: + if value is None: + return 'null' + return str(value) + + +def _render_template(template: str, model: dict[str, Any]) -> str: + tokens, _ = _parse_nodes(template, 0, set()) + return _render_nodes(tokens, model) + + +def _parse_nodes(template: str, start: int, stop_tags: set[str]) -> tuple[list[dict[str, Any]], int]: + index = start + nodes: list[dict[str, Any]] = [] + + while index < len(template): + marker = template.find('{{', index) + if marker < 0: + if index < len(template): + nodes.append({'type': 'text', 'value': template[index:]}) + return nodes, len(template) + + if marker > index: + nodes.append({'type': 'text', 'value': template[index:marker]}) + + if template.startswith('{{{', marker): + close = template.find('}}}', marker + 3) + if close < 0: + nodes.append({'type': 'text', 'value': template[marker:]}) + return nodes, len(template) + + expression = template[marker + 3:close].strip() + nodes.append({'type': 'raw', 'expression': expression}) + index = close + 3 + continue + + close = template.find('}}', marker + 2) + if close < 0: + nodes.append({'type': 'text', 'value': template[marker:]}) + return nodes, len(template) + + expression = template[marker + 2:close].strip() + index = close + 2 + + if not expression: + continue + + if expression in stop_tags: + return nodes, marker + + if expression.startswith('#if '): + condition = expression[4:].strip() + true_nodes, branch_pos = _parse_nodes(template, index, {'else', '/if'}) + false_nodes: list[dict[str, Any]] = [] + + branch_marker_end = _advance_tag_end(template, branch_pos) + branch_expression = _read_tag_expression(template, branch_pos) + + if branch_expression == 'else': + false_nodes, end_if_pos = _parse_nodes(template, branch_marker_end, {'/if'}) + index = _advance_tag_end(template, end_if_pos) + else: + index = branch_marker_end + + nodes.append( + { + 'type': 'if', + 'condition': condition, + 'true_nodes': true_nodes, + 'false_nodes': false_nodes, + } + ) + continue + + if expression.startswith('#each '): + collection = expression[6:].strip() + each_nodes, end_each_pos = _parse_nodes(template, index, {'/each'}) + index = _advance_tag_end(template, end_each_pos) + nodes.append({'type': 'each', 'collection': collection, 'nodes': each_nodes}) + continue + + nodes.append({'type': 'var', 'expression': expression}) + + return nodes, index + + +def _advance_tag_end(template: str, marker: int) -> int: + if marker >= len(template): + return marker + + close = template.find('}}', marker + 2) + if close < 0: + return len(template) + return close + 2 + + +def _read_tag_expression(template: str, marker: int) -> str: + if marker >= len(template) or not template.startswith('{{', marker): + return '' + close = template.find('}}', marker + 2) + if close < 0: + return '' + return template[marker + 2:close].strip() + + +def _render_nodes(nodes: list[dict[str, Any]], context: dict[str, Any]) -> str: + parts: list[str] = [] + + for node in nodes: + node_type = node.get('type') + if node_type == 'text': + parts.append(node.get('value', '')) + continue + + if node_type == 'var': + value = _resolve_expression(context, node.get('expression', '')) + parts.append(_escape(_stringify(value))) + continue + + if node_type == 'raw': + value = _resolve_expression(context, node.get('expression', '')) + parts.append(_stringify(value)) + continue + + if node_type == 'if': + condition_value = _resolve_expression(context, node.get('condition', '')) + branch = node.get('true_nodes', []) if _is_truthy(condition_value) else node.get('false_nodes', []) + parts.append(_render_nodes(branch, context)) + continue + + if node_type == 'each': + collection_value = _resolve_expression(context, node.get('collection', '')) + if isinstance(collection_value, list): + for item in collection_value: + loop_context = _child_context(context, item) + parts.append(_render_nodes(node.get('nodes', []), loop_context)) + continue + + return ''.join(parts) + + +def _child_context(parent: dict[str, Any], item: Any) -> dict[str, Any]: + return { + '__parent__': parent, + 'this': item, + } + + +def _resolve_expression(context: dict[str, Any], expression: str) -> Any: + path = expression.strip() + if not path: + return '' + + if path == 'this': + return context.get('this', '') + + segments = path.split('.') + value = _resolve_root_value(context, segments[0]) + for segment in segments[1:]: + value = _resolve_segment(value, segment) + if value is None: + return '' + return value + + +def _resolve_root_value(context: dict[str, Any], key: str) -> Any: + if key == 'this': + return context.get('this') + + if key in context: + return context[key] + + current_item = context.get('this') + if isinstance(current_item, dict) and key in current_item: + return current_item[key] + + parent = context.get('__parent__') + if isinstance(parent, dict): + return _resolve_root_value(parent, key) + + return '' + + +def _resolve_segment(value: Any, segment: str) -> Any: + if isinstance(value, dict): + return value.get(segment) + return getattr(value, segment, None) + + +def _is_truthy(value: Any) -> bool: + return bool(value) + + +def _stringify(value: Any) -> str: + if value is None: + return '' + return str(value) + + +def _escape(value: Any) -> str: + return html.escape(str(value), quote=True) diff --git a/templates/summary.html b/templates/summary.html new file mode 100644 index 0000000..1d15f0c --- /dev/null +++ b/templates/summary.html @@ -0,0 +1,159 @@ +
+ + + {{#if isMissing}} +

Summary input is missing

+ {{else}} +
+
JSONL files
{{metrics.jsonlFilesFormatted}}
+
Files analyzed
{{metrics.filesTotalFormatted}}
+
Coverage
{{metrics.runFilesAnalyzedFormatted}} / {{metrics.runTotalFilesFormatted}} ({{metrics.runCoveragePercent}}%)
+
Files with errors
{{metrics.runFilesWithErrorsFormatted}}
+
Duration (s)
{{metrics.runDurationSecondsFormatted}}
+
Types
{{metrics.typesTotalFormatted}}
+
Extends
{{metrics.extendsTotalFormatted}}
+
Implements
{{metrics.implementsTotalFormatted}}
+
Mixins
{{metrics.mixinsTotalFormatted}}
+
Method call edges
{{metrics.methodCallEdgesTotalFormatted}}
+
Method call total
{{metrics.methodCallCountTotalFormatted}}
+
Generated at
{{generatedAt}}
+
+ + {{#if isDataQualityPartial}} +

+ Data quality issues detected (invalid lines: {{metrics.invalidLinesFormatted}}, parse failures: {{metrics.parseFailuresFormatted}}). +

+ {{/if}} + +

Structural Metrics by Language

+ + + + + + + + + + + + + + {{#each languageRows}} + + + + + + + + + + {{/each}} + +
LanguageFilesShareTypesMethodsFields/PropsRelationship Links
{{language}}{{filesFormatted}}{{share}}{{typesFormatted}}{{methodsFormatted}}{{fieldsFormatted}}{{relationshipsFormatted}}
+ +

Type Kind Breakdown

+ + + + + + + + + {{#each typeKindRows}} + + + + + {{/each}} + +
KindCount
{{kind}}{{countFormatted}}
+ + {{#if hasSqlMetrics}} +

SQL Structural Footprint

+
+
SQL files
{{sqlMetrics.filesFormatted}}
+
CREATE TABLE
{{sqlMetrics.createTablesFormatted}}
+
ALTER TABLE
{{sqlMetrics.alterTablesFormatted}}
+
CREATE VIEW
{{sqlMetrics.createViewsFormatted}}
+
CREATE INDEX
{{sqlMetrics.createIndexesFormatted}}
+
CREATE PROCEDURE
{{sqlMetrics.createProceduresFormatted}}
+
CREATE FUNCTION
{{sqlMetrics.createFunctionsFormatted}}
+
CREATE TRIGGER
{{sqlMetrics.createTriggersFormatted}}
+
DROP operations
{{sqlMetrics.dropOperationsFormatted}}
+
+ {{/if}} + + {{#if hasCobolMetrics}} +

COBOL Structural Footprint

+
+
COBOL files
{{cobolMetrics.filesFormatted}}
+
Sections
{{cobolMetrics.sectionsFormatted}}
+
Paragraphs
{{cobolMetrics.paragraphsFormatted}}
+
Data items
{{cobolMetrics.dataItemsFormatted}}
+
Copy statements
{{cobolMetrics.copyStatementsFormatted}}
+
File definitions
{{cobolMetrics.fileDefinitionsFormatted}}
+
+ {{/if}} + + {{#if hasMarkdownMetrics}} +

Markdown Structural Footprint

+
+
Markdown files
{{markdownMetrics.filesFormatted}}
+
Sections
{{markdownMetrics.sectionsFormatted}}
+
Elements
{{markdownMetrics.elementsFormatted}}
+
+ {{/if}} + {{/if}} +
From 117e57d96a78ccfbe2fbd463b26995487579c382 Mon Sep 17 00:00:00 2001 From: casianaoprut Date: Fri, 3 Apr 2026 15:27:19 +0300 Subject: [PATCH 2/4] updated instrument.v2.yml --- instrument.v2.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/instrument.v2.yml b/instrument.v2.yml index 8ee5bd3..10b4eed 100644 --- a/instrument.v2.yml +++ b/instrument.v2.yml @@ -1,6 +1,6 @@ name: codeframe id: codeframe -version: 1.0.0 +version: 0.6.3 actions: start: @@ -14,7 +14,7 @@ actions: summary: md-file: results/summary.md html-file: results/summary.html - category: Structural Relationship + category: Structural Relations commands: generate-summary: id: generate-summary From 55f0d766075466172055e960d97551f8aff7ee43 Mon Sep 17 00:00:00 2001 From: casianaoprut Date: Tue, 7 Apr 2026 17:23:40 +0300 Subject: [PATCH 3/4] streamline summary metrics and language breakdown table --- summary_extract.py | 37 +++++-------------------------------- templates/summary.html | 11 +++-------- 2 files changed, 8 insertions(+), 40 deletions(-) diff --git a/summary_extract.py b/summary_extract.py index 3c40ab1..16de6ff 100644 --- a/summary_extract.py +++ b/summary_extract.py @@ -1,7 +1,6 @@ from __future__ import annotations import json -from datetime import datetime from pathlib import Path from typing import Any @@ -392,7 +391,6 @@ def _create_summary_payload( language_metrics: dict[str, dict[str, int]] | None = None, has_data_quality_issues: bool = True, ) -> dict[str, Any]: - generated_at = _iso_now() sql_metrics = sql_metrics or { 'files': 0, @@ -424,12 +422,10 @@ def _create_summary_payload( type_kind_rows = _build_type_kind_rows(type_kinds) metadata: dict[str, Any] = { - 'jsonl.files': len(jsonl_files), 'files.total': files_total, 'run.total.files': run_expected_total, 'run.files.analyzed': run_files_analyzed, 'run.files.with.errors': run_files_with_errors, - 'run.duration.seconds': run_duration_seconds, 'run.coverage.percent': coverage_percent, 'languages.count': len(languages), 'types.total': metrics['typesTotal'], @@ -441,7 +437,6 @@ def _create_summary_payload( 'method.calls.total': metrics['methodCallCountTotal'], 'data.invalid.lines': invalid_lines, 'data.parse.failures': parse_failures, - 'generated.at': generated_at, } for language, count in sorted(languages.items()): @@ -453,11 +448,9 @@ def _create_summary_payload( markdown_lines: list[str] = [ '## CodeFrame', '', - f'- JSONL files: {_format_int(len(jsonl_files))}', f'- Files analyzed: {_format_int(files_total)}', f'- Coverage: {_format_int(run_files_analyzed)}/{_format_int(run_expected_total)} ({coverage_percent}%)', f'- Files with errors: {_format_int(run_files_with_errors)}', - f'- Duration: {_format_int(run_duration_seconds)} s', f'- Type declarations: {_format_int(metrics["typesTotal"])}', f'- Method call edges: {_format_int(metrics["methodCallEdgesTotal"])}', f'- Relationship links (extends/implements/mixins): ' @@ -465,20 +458,20 @@ def _create_summary_payload( '', '### Structural Metrics by Language', '', - '| Language | Files | Share | Types | Methods | Fields/Props | Relationship Links |', - '| --- | ---: | ---: | ---: | ---: | ---: | ---: |', + '| Language | Files (%) | Types | Methods | Fields/Props | Relationship Links |', + '| --- | ---: | ---: | ---: | ---: | ---: |', ] for row in language_rows: markdown_lines.append( - f"| {row['language']} | {row['filesFormatted']} | {row['share']} | {row['typesFormatted']} " + f"| {row['language']} | {row['filesWithShareFormatted']} | {row['typesFormatted']} " f"| {row['methodsFormatted']} | {row['fieldsFormatted']} | {row['relationshipsFormatted']} |" ) markdown_lines.extend( [ '', - '### Type Kind Breakdown', + '### Type Breakdown', '', '| Kind | Count |', '| --- | ---: |', @@ -533,16 +526,13 @@ def _create_summary_payload( ) template_model = { - 'generatedAt': generated_at, 'isDataQualityPartial': has_data_quality_issues, 'metrics': { - 'jsonlFilesFormatted': _format_int(len(jsonl_files)), 'filesTotalFormatted': _format_int(files_total), 'runTotalFilesFormatted': _format_int(run_expected_total), 'runFilesAnalyzedFormatted': _format_int(run_files_analyzed), 'runFilesWithErrorsFormatted': _format_int(run_files_with_errors), 'runCoveragePercent': coverage_percent, - 'runDurationSecondsFormatted': _format_int(run_duration_seconds), 'typesTotalFormatted': _format_int(metrics['typesTotal']), 'extendsTotalFormatted': _format_int(metrics['extendsTotal']), 'implementsTotalFormatted': _format_int(metrics['implementsTotal']), @@ -620,6 +610,7 @@ def _build_language_rows( 'fieldsFormatted': _format_int(fields), 'relationshipsFormatted': _format_int(relationships), 'share': f"{_percent(files, files_total)}%", + 'filesWithShareFormatted': f"{_format_int(files)} ({_percent(files, files_total)}%)", } ) @@ -745,21 +736,3 @@ def _percent(value: int, total: int) -> str: def _format_int(value: int) -> str: return f'{value:,}' - -def _iso_now() -> str: - local_now = datetime.now().astimezone() - return f"{local_now.strftime('%Y-%m-%d %H:%M:%S')} {_format_gmt_offset(local_now.strftime('%z'))}" - - -def _format_gmt_offset(offset: str) -> str: - if len(offset) != 5: - return 'GMT+0' - - sign = offset[0] - hours = int(offset[1:3]) - minutes = int(offset[3:5]) - - if minutes == 0: - return f'GMT{sign}{hours}' - - return f'GMT{sign}{hours}:{minutes:02d}' diff --git a/templates/summary.html b/templates/summary.html index 1d15f0c..caa23f2 100644 --- a/templates/summary.html +++ b/templates/summary.html @@ -54,18 +54,15 @@

Summary input is missing

{{else}}
-
JSONL files
{{metrics.jsonlFilesFormatted}}
Files analyzed
{{metrics.filesTotalFormatted}}
Coverage
{{metrics.runFilesAnalyzedFormatted}} / {{metrics.runTotalFilesFormatted}} ({{metrics.runCoveragePercent}}%)
Files with errors
{{metrics.runFilesWithErrorsFormatted}}
-
Duration (s)
{{metrics.runDurationSecondsFormatted}}
Types
{{metrics.typesTotalFormatted}}
Extends
{{metrics.extendsTotalFormatted}}
Implements
{{metrics.implementsTotalFormatted}}
Mixins
{{metrics.mixinsTotalFormatted}}
Method call edges
{{metrics.methodCallEdgesTotalFormatted}}
Method call total
{{metrics.methodCallCountTotalFormatted}}
-
Generated at
{{generatedAt}}
{{#if isDataQualityPartial}} @@ -79,8 +76,7 @@

Structural Metrics by Language

Language - Files - Share + Files (%) Types Methods Fields/Props @@ -91,8 +87,7 @@

Structural Metrics by Language

{{#each languageRows}} {{language}} - {{filesFormatted}} - {{share}} + {{filesWithShareFormatted}} {{typesFormatted}} {{methodsFormatted}} {{fieldsFormatted}} @@ -102,7 +97,7 @@

Structural Metrics by Language

-

Type Kind Breakdown

+

Type Breakdown

From b99d755f8dbcbf16fcb52fc1e4228c10a6d7adf7 Mon Sep 17 00:00:00 2001 From: casianaoprut Date: Thu, 9 Apr 2026 14:04:13 +0300 Subject: [PATCH 4/4] streamline type breakdown into compact summary chips --- templates/summary.html | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/templates/summary.html b/templates/summary.html index caa23f2..42f5e0f 100644 --- a/templates/summary.html +++ b/templates/summary.html @@ -23,6 +23,17 @@ border-radius: 6px; } + .codeframe-summary .type-breakdown-grid { + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 6px 10px; + } + + .codeframe-summary .type-breakdown-grid .summary-item { + padding: 4px 6px; + font-size: 0.9rem; + line-height: 1.2; + } + .codeframe-summary .summary-note { margin: 8px 0; color: #b54708; @@ -98,22 +109,15 @@

Structural Metrics by Language

Type Breakdown

- - - - - - - - + {{#if typeKindRows}} +
{{#each typeKindRows}} -
- - - +
{{kind}}
{{countFormatted}}
{{/each}} - -
KindCount
{{kind}}{{countFormatted}}
+ + {{else}} +

No type breakdown data available.

+ {{/if}} {{#if hasSqlMetrics}}

SQL Structural Footprint