From c69859d04d3b9d58c87625b5814c7c2ac1a2b5b7 Mon Sep 17 00:00:00 2001 From: casianaoprut Date: Wed, 18 Mar 2026 16:48:30 +0200 Subject: [PATCH 01/13] added AGENTS.md file and summary command --- AGENTS.md | 159 ++++++++++++++++++++ instrument.v2.yml | 32 ++++ lizard-summary.py | 35 +++++ summary_extract.py | 327 +++++++++++++++++++++++++++++++++++++++++ summary_render.py | 271 ++++++++++++++++++++++++++++++++++ templates/summary.html | 100 +++++++++++++ 6 files changed, 924 insertions(+) create mode 100644 AGENTS.md create mode 100644 instrument.v2.yml create mode 100644 lizard-summary.py create mode 100644 summary_extract.py create mode 100644 summary_render.py create mode 100644 templates/summary.html diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..0a2d8a4e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,159 @@ +# AGENTS Guide for lizard +This file is the default playbook for agentic coding tools in this repository. + +## Project Overview +- Language: Python, with legacy compatibility patterns in core modules. +- Main code: `lizard.py`, `lizard_ext/`, `lizard_languages/`. +- Tests: `test/` (mostly `unittest` style, usually run with `pytest`). +- Build/packaging: `setup.py`, `setup.cfg`, `Makefile`. + +## Repository Layout +- `lizard.py`: CLI entrypoint and analysis orchestration. +- `lizard_ext/`: extension modules and output backends. +- `lizard_languages/`: parser/readers and language state machines. +- `test/`: analyzer, extension, output, and language tests. +- `dev_requirements.txt`: dev/lint/test dependencies. +- `pylintrc`: style, naming, and lint constraints. + +## Environment Setup +Run from repo root: + +```bash +python -m pip install -e . +python -m pip install -r dev_requirements.txt +python setup.py build install +``` + +Optional all-in-one setup: + +```bash +bash build.sh +``` + +## Build/Lint/Test Commands +Canonical `Makefile` targets: + +```bash +make # extensive + pylint +make extensive # tests + pep8 +make tests # coverage run -m pytest test; coverage report -m +make tests3 # python3 -m unittest test +make pep8 # pycodestyle lizard.py lizard_ext lizard_languages +make pylint # pylint --exit-zero --rcfile pylintrc lizard.py lizard_ext lizard_languages +make build # python3 setup.py sdist && python3 setup.py bdist_wheel +``` + +Direct alternatives: + +```bash +pytest test +pytest -q test +python -m unittest test +``` + +### Running a Single Test +Preferred patterns: + +```bash +pytest test/test_analyzer.py::TestWarningFilter::test_should_filter_the_warnings +pytest test/test_analyzer.py -k should_filter_the_warnings +python -m unittest test.test_analyzer.TestWarningFilter.test_should_filter_the_warnings +``` + +Focused subsets: + +```bash +pytest test/test_languages +pytest test/test_extensions +pytest test/test_output.py +``` + +## CI/Release Context +- Historical CI files exist: `.travis.yml`, `.appveyor.yml`. +- GitHub workflow exists: `.github/workflows/release-voyager.yml` (release tags only). +- There is no current PR-gating CI workflow in this repo. +- Agents should run relevant tests/lint locally before finalizing changes. + +## Code Style Guidelines + +### Compatibility and Syntax +- Preserve legacy-compatible style in core modules. +- Keep `from __future__` imports when present in a file. +- Avoid Python-3-only syntax in legacy paths unless surrounding code already uses it. +- Avoid introducing annotation syntax that can break older runtime expectations. + +### Formatting +- Follow `pylintrc` defaults: 4-space indentation, 80-char line length. +- Wrap long expressions instead of exceeding line limits. +- Keep edits focused; avoid drive-by formatting changes. + +### Imports +- Use this import order: + 1. `__future__` imports. + 2. Standard library. + 3. Third-party packages. + 4. Local modules. +- Prefer explicit imports over wildcard imports. +- Match local style in touched files when uncertain. + +### Naming Conventions +`pylintrc` naming regexes are authoritative: + +- Modules: `([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+)`. +- Functions/methods/variables/args: `snake_case` in `[a-z_][a-z0-9_]{2,30}`. +- Classes: `PascalCase` in `[A-Z_][a-zA-Z0-9]+`. +- Constants: `UPPER_CASE` in `[A-Z_][A-Z0-9_]*`. + +Additional naming notes: +- Some tests use legacy names (for example `test_OneFile`); follow local file style. +- Prefer descriptive names unless common domain terms already exist (`CCN`, `nloc`). + +### Types and Data Structures +- The repo is not heavily type-hinted. +- Prioritize runtime clarity/compatibility over annotation-heavy refactors. +- Reuse established domain classes (`FunctionInfo`, `FileInformation`, etc.). + +### Error Handling +- Treat file/read/encoding/parser failures as recoverable where possible. +- Catch specific exceptions before broad ones. +- In CLI flows, align with existing messaging style (`sys.stderr.write(...)`). +- Do not silently swallow exceptions unless behavior intentionally requires it. + +### Testing +- Add/update tests for any behavior change. +- Prefer targeted regression tests for bug fixes. +- Keep tests deterministic and scoped. +- If parser logic changes, run relevant tests in `test/test_languages/`. + +### Linting +- Run `make pep8` and `make pylint` (or equivalent direct commands) for touched areas. +- Keep pylint suppressions minimal and justified. +- Do not remove existing suppressions unless explicitly part of the task. + +## Agent Working Rules +- Read nearby code before editing; mirror local idioms. +- Avoid broad refactors unless explicitly requested. +- Keep CLI options/output behavior backward-compatible unless task requires changes. +- Do not edit vendored assets under `website/static/bower/` unless required. + +## Cursor/Copilot Instruction Files +Checked locations: +- `.cursorrules` +- `.cursor/rules/` +- `.github/copilot-instructions.md` + +Current status: +- No Cursor rules found. +- No Copilot instruction file found. + +If these files appear later, treat them as higher-priority repo instructions and update this guide. + +## Quick Agent Checklist + +```bash +pytest test +make pep8 +make pylint +``` + +For small scoped changes, run at least one relevant single-test command plus the most relevant lint/test subset. diff --git a/instrument.v2.yml b/instrument.v2.yml new file mode 100644 index 00000000..a33a295a --- /dev/null +++ b/instrument.v2.yml @@ -0,0 +1,32 @@ +name: lizard +id: lizard +version: 1.0.0 + +actions: + start: + commands: + run-lizard: + id: run-lizard + dir: ${instrumentPath} + command: + windows: >- + python lizard.py -V --csv "${repo}" > "${instrumentPath}/results/${repoName}.csv" + unix: >- + python lizard.py -V --csv "${repo}" > "${instrumentPath}/results/${repoName}.csv" + + summary: + summaryMdFile: results/summary.md + summaryHtmlFile: results/summary.html + commands: + generate-summary: + id: generate-summary + dir: ${instrumentPath} + command: + windows: py -3 lizard-summary.py "${instrumentPath}/results" + unix: python3 lizard-summary.py "${instrumentPath}/results" + + pack: + with: + locations: + - source: ${instrumentDir}/results + destination: /results diff --git a/lizard-summary.py b/lizard-summary.py new file mode 100644 index 00000000..d4bc350b --- /dev/null +++ b/lizard-summary.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +from pathlib import Path + +from summary_extract import extract_lizard_summary +from summary_render import render_summary + + +def main() -> int: + parser = argparse.ArgumentParser( + prog='lizard-summary.py', + description='Generates lizard summary artifacts for Voyager', + ) + parser.add_argument('results_directory', nargs='?', default='results') + args = parser.parse_args() + + target_directory = Path(args.results_directory).resolve() + + try: + payload = extract_lizard_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/summary_extract.py b/summary_extract.py new file mode 100644 index 00000000..f4db7453 --- /dev/null +++ b/summary_extract.py @@ -0,0 +1,327 @@ +from __future__ import annotations + +import csv +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + + +@dataclass(frozen=True) +class ParsedLizardCsv: + functions_total: int + unique_files: set[str] + nloc_total: int + ccn_total: int + length_total: int + max_ccn: int + max_length: int + top_functions: list[dict[str, Any]] + had_parse_failure: bool + invalid_rows: int + + +def extract_lizard_summary(results_directory: str | Path) -> dict[str, Any]: + target = Path(results_directory) + + try: + entries = list(target.iterdir()) + except Exception: + return _create_summary_payload( + csv_files=[], + functions_total=0, + unique_files=set(), + nloc_total=0, + ccn_total=0, + length_total=0, + max_ccn=0, + max_length=0, + top_functions=[], + has_data_quality_issues=True, + ) + + csv_files = sorted( + [ + entry + for entry in entries + if entry.is_file() and entry.suffix.lower() == '.csv' and entry.name != 'summary.csv' + ], + key=lambda value: value.name, + ) + + functions_total = 0 + unique_files: set[str] = set() + nloc_total = 0 + ccn_total = 0 + length_total = 0 + max_ccn = 0 + max_length = 0 + top_functions: list[dict[str, Any]] = [] + had_parse_failures = False + invalid_rows_total = 0 + + for csv_file in csv_files: + parsed = _parse_lizard_csv(csv_file) + functions_total += parsed.functions_total + unique_files.update(parsed.unique_files) + nloc_total += parsed.nloc_total + ccn_total += parsed.ccn_total + length_total += parsed.length_total + max_ccn = max(max_ccn, parsed.max_ccn) + max_length = max(max_length, parsed.max_length) + invalid_rows_total += parsed.invalid_rows + had_parse_failures = had_parse_failures or parsed.had_parse_failure + + top_functions.extend(parsed.top_functions) + + top_functions = _pick_top_functions(top_functions, limit=10) + has_data_quality_issues = had_parse_failures or invalid_rows_total > 0 or functions_total == 0 + + return _create_summary_payload( + csv_files=csv_files, + functions_total=functions_total, + unique_files=unique_files, + nloc_total=nloc_total, + ccn_total=ccn_total, + length_total=length_total, + max_ccn=max_ccn, + max_length=max_length, + top_functions=top_functions, + has_data_quality_issues=has_data_quality_issues, + ) + + +def _parse_lizard_csv(file_path: Path) -> ParsedLizardCsv: + functions_total = 0 + unique_files: set[str] = set() + nloc_total = 0 + ccn_total = 0 + length_total = 0 + max_ccn = 0 + max_length = 0 + top_functions: list[dict[str, Any]] = [] + had_parse_failure = False + invalid_rows = 0 + + try: + with file_path.open('r', encoding='utf-8', errors='replace', newline='') as handle: + reader = csv.reader(handle) + header = next(reader, None) + column_indexes = _resolve_column_indexes(header) + if column_indexes is None: + return ParsedLizardCsv( + functions_total=0, + unique_files=set(), + nloc_total=0, + ccn_total=0, + length_total=0, + max_ccn=0, + max_length=0, + top_functions=[], + had_parse_failure=True, + invalid_rows=0, + ) + + for row in reader: + if len(row) <= column_indexes['length']: + invalid_rows += 1 + continue + + try: + nloc_value = int(row[column_indexes['nloc']]) + ccn_value = int(row[column_indexes['ccn']]) + length_value = int(row[column_indexes['length']]) + except (TypeError, ValueError): + invalid_rows += 1 + continue + + file_value = row[column_indexes['file']].strip() + function_name = row[column_indexes['function']].strip() + location_value = row[column_indexes['location']].strip() + + functions_total += 1 + nloc_total += nloc_value + ccn_total += ccn_value + length_total += length_value + max_ccn = max(max_ccn, ccn_value) + max_length = max(max_length, length_value) + + if file_value: + unique_files.add(file_value) + + top_functions.append( + { + 'function': function_name or 'unknown', + 'file': file_value or 'unknown', + 'location': location_value or 'unknown', + 'ccn': ccn_value, + 'nloc': nloc_value, + 'length': length_value, + } + ) + except Exception: + had_parse_failure = True + + return ParsedLizardCsv( + functions_total=functions_total, + unique_files=unique_files, + nloc_total=nloc_total, + ccn_total=ccn_total, + length_total=length_total, + max_ccn=max_ccn, + max_length=max_length, + top_functions=_pick_top_functions(top_functions, limit=10), + had_parse_failure=had_parse_failure, + invalid_rows=invalid_rows, + ) + + +def _resolve_column_indexes(header: list[str] | None) -> dict[str, int] | None: + if not header: + return None + + normalized = [value.strip().lower() for value in header] + required = { + 'nloc': 'nloc', + 'ccn': 'ccn', + 'length': 'length', + 'location': 'location', + 'file': 'file', + 'function': 'function', + } + + indexes: dict[str, int] = {} + for key, column_name in required.items(): + if column_name not in normalized: + return None + indexes[key] = normalized.index(column_name) + + return indexes + + +def _pick_top_functions(candidates: list[dict[str, Any]], limit: int) -> list[dict[str, Any]]: + ordered = sorted( + candidates, + key=lambda item: ( + -int(item.get('ccn', 0)), + -int(item.get('nloc', 0)), + -int(item.get('length', 0)), + str(item.get('function', '')), + ), + ) + return ordered[:limit] + + +def _create_summary_payload( + csv_files: list[Path], + functions_total: int, + unique_files: set[str], + nloc_total: int, + ccn_total: int, + length_total: int, + max_ccn: int, + max_length: int, + top_functions: list[dict[str, Any]], + has_data_quality_issues: bool, +) -> dict[str, Any]: + generated_at = _iso_now() + average_ccn = _format_average(ccn_total, functions_total) + average_length = _format_average(length_total, functions_total) + status = _resolve_status(csv_count=len(csv_files), has_data_quality_issues=has_data_quality_issues) + + metadata = { + 'metadata.csv.files': len(csv_files), + 'metadata.files.unique': len(unique_files), + 'metadata.functions.total': functions_total, + 'metadata.nloc.total': nloc_total, + 'metadata.ccn.average': average_ccn, + 'metadata.ccn.max': max_ccn, + 'metadata.length.average': average_length, + 'metadata.length.max': max_length, + 'metadata.generated.at': generated_at, + } + + markdown_lines = [ + '## Lizard', + '', + f'- Status: {status}', + f'- CSV files: {len(csv_files)}', + f'- Unique files analyzed: {len(unique_files)}', + f'- Functions analyzed: {functions_total}', + f'- Total NLOC: {nloc_total}', + f'- Average CCN: {average_ccn}', + f'- Max CCN: {max_ccn}', + f'- Average length: {average_length}', + f'- Max length: {max_length}', + '', + '### Top Complex Functions', + '', + '| Function | File | CCN | NLOC | Length |', + '| --- | --- | ---: | ---: | ---: |', + ] + + if not top_functions: + markdown_lines.append('| _none_ | _none_ | 0 | 0 | 0 |') + else: + for row in top_functions: + markdown_lines.append( + f"| {row.get('function', 'unknown')} | {row.get('file', 'unknown')} | {row.get('ccn', 0)} | " + f"{row.get('nloc', 0)} | {row.get('length', 0)} |" + ) + + template_model = { + 'status': status, + 'statusClass': _to_status_class(status), + 'generatedAt': generated_at, + 'metrics': { + 'csvFiles': len(csv_files), + 'uniqueFiles': len(unique_files), + 'functionsTotal': functions_total, + 'nlocTotal': nloc_total, + 'averageCcn': average_ccn, + 'maxCcn': max_ccn, + 'averageLength': average_length, + 'maxLength': max_length, + }, + 'topFunctions': top_functions, + } + + return { + 'tool': 'lizard', + 'status': status, + 'metadata': metadata, + 'markdown': '\n'.join(markdown_lines), + 'templateModel': template_model, + } + + +def _format_average(total: int, count: int) -> str: + if count <= 0: + return '0' + + average = float(total) / float(count) + if average.is_integer(): + return str(int(average)) + return f'{average:.2f}'.rstrip('0').rstrip('.') + + +def _resolve_status(csv_count: int, has_data_quality_issues: bool) -> str: + if csv_count == 0: + return 'failed' + if has_data_quality_issues: + return 'partial' + return 'success' + + +def _iso_now() -> str: + return datetime.now(timezone.utc).isoformat(timespec='milliseconds').replace('+00:00', 'Z') + + +def _to_status_class(status: str) -> str: + if status == 'success': + return 'status-success' + if status == 'partial': + return 'status-warning' + if status == 'failed': + return 'status-error' + return 'status-unknown' diff --git a/summary_render.py b/summary_render.py new file mode 100644 index 00000000..60455c8f --- /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}}

Status: {{status}}

' + + +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 00000000..7c6f88b7 --- /dev/null +++ b/templates/summary.html @@ -0,0 +1,100 @@ +
+ + +

Lizard

+

Status: {{status}}

+ +
+
CSV files
{{metrics.csvFiles}}
+
Unique files
{{metrics.uniqueFiles}}
+
Functions
{{metrics.functionsTotal}}
+
Total NLOC
{{metrics.nlocTotal}}
+
Average CCN
{{metrics.averageCcn}}
+
Max CCN
{{metrics.maxCcn}}
+
Average length
{{metrics.averageLength}}
+
Max length
{{metrics.maxLength}}
+
Generated at
{{generatedAt}}
+
+ +

Top Complex Functions

+ + + + + + + + + + + + {{#if topFunctions}} + {{#each topFunctions}} + + + + + + + + {{/each}} + {{else}} + + {{/if}} + +
FunctionFileCCNNLOCLength
{{this.function}}{{this.file}}{{this.ccn}}{{this.nloc}}{{this.length}}
No function metrics available.
+
From 656e55fe3688935cb62b68fc1b6ed37cd386f8ce Mon Sep 17 00:00:00 2001 From: casianaoprut Date: Wed, 18 Mar 2026 16:49:05 +0200 Subject: [PATCH 02/13] updated github workflow --- .github/workflows/release-voyager.yml | 33 ++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release-voyager.yml b/.github/workflows/release-voyager.yml index 2471648f..62c3677e 100644 --- a/.github/workflows/release-voyager.yml +++ b/.github/workflows/release-voyager.yml @@ -12,7 +12,27 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v4 + + - name: Prepare Assets + run: | + mkdir -p lizard/results + mkdir -p lizard/templates + + cp README.rst lizard/README.rst + cp LICENSE.txt lizard/LICENSE.txt + cp instrument.yml lizard/instrument.yml + cp instrument.v2.yml lizard/instrument.v2.yml + cp lizard.py lizard/lizard.py + cp summary_extract.py lizard/summary_extract.py + cp summary_render.py lizard/summary_render.py + cp lizard-summary.py lizard/lizard-summary.py + cp templates/summary.html lizard/templates/summary.html + cp -R lizard_ext lizard/lizard_ext + cp -R lizard_languages lizard/lizard_languages + + - name: Create Archive + run: zip -r lizard.zip lizard - name: Create Release id: create_release @@ -24,3 +44,14 @@ jobs: release_name: ${{ github.ref }} (Voyager) draft: false prerelease: false + + - name: Upload Release Asset + id: upload-release-asset + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ./lizard.zip + asset_name: lizard-voyager.zip + asset_content_type: application/zip From a8f050e7bf4f992f5e7a74147707506e77cc62d3 Mon Sep 17 00:00:00 2001 From: casianaoprut Date: Thu, 19 Mar 2026 17:28:33 +0200 Subject: [PATCH 03/13] fixed date format in summary --- summary_extract.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/summary_extract.py b/summary_extract.py index f4db7453..3bf59d1a 100644 --- a/summary_extract.py +++ b/summary_extract.py @@ -314,7 +314,7 @@ def _resolve_status(csv_count: int, has_data_quality_issues: bool) -> str: def _iso_now() -> str: - return datetime.now(timezone.utc).isoformat(timespec='milliseconds').replace('+00:00', 'Z') + return datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC') def _to_status_class(status: str) -> str: From bbcc0e906275383c42c8cd69a563312ca55668ac Mon Sep 17 00:00:00 2001 From: casianaoprut Date: Mon, 23 Mar 2026 17:07:47 +0200 Subject: [PATCH 04/13] handled missing summary input --- lizard-summary.py | 33 ++++++++++++++++- templates/summary.html | 84 +++++++++++++++++++++++------------------- 2 files changed, 78 insertions(+), 39 deletions(-) diff --git a/lizard-summary.py b/lizard-summary.py index d4bc350b..6ec4e527 100644 --- a/lizard-summary.py +++ b/lizard-summary.py @@ -9,6 +9,25 @@ from summary_render import render_summary +def build_missing_payload() -> dict[str, object]: + return { + 'tool': 'lizard', + 'status': 'missing', + 'metadata': {}, + 'markdown': '\n'.join([ + '## Lizard', + '', + '- Status: missing', + '- Summary input is missing', + ]), + 'templateModel': { + 'status': 'missing', + 'statusClass': 'status-missing', + 'isMissing': True, + }, + } + + def main() -> int: parser = argparse.ArgumentParser( prog='lizard-summary.py', @@ -20,7 +39,19 @@ def main() -> int: target_directory = Path(args.results_directory).resolve() try: - payload = extract_lizard_summary(target_directory) + csv_files = [ + file_path + for file_path in target_directory.glob('*.csv') + if file_path.name != 'summary.csv' + ] + if len(csv_files) == 0: + print( + "summary input missing for lizard: expected '*.csv' files (excluding " + f"'summary.csv') in '{target_directory}'; generating missing summary artifacts" + ) + payload = build_missing_payload() + else: + payload = extract_lizard_summary(target_directory) rendered = render_summary(target_directory, payload) print(f"Generated summary markdown at {rendered['summaryMdPath']}") diff --git a/templates/summary.html b/templates/summary.html index 7c6f88b7..10e858fc 100644 --- a/templates/summary.html +++ b/templates/summary.html @@ -27,6 +27,10 @@ border-left: 4px solid #cf222e; } + .lizard-summary.status-missing { + border-left: 4px solid #8c959f; + } + .lizard-summary .summary-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); @@ -58,43 +62,47 @@

Lizard

Status: {{status}}

-
-
CSV files
{{metrics.csvFiles}}
-
Unique files
{{metrics.uniqueFiles}}
-
Functions
{{metrics.functionsTotal}}
-
Total NLOC
{{metrics.nlocTotal}}
-
Average CCN
{{metrics.averageCcn}}
-
Max CCN
{{metrics.maxCcn}}
-
Average length
{{metrics.averageLength}}
-
Max length
{{metrics.maxLength}}
-
Generated at
{{generatedAt}}
-
+ {{#if isMissing}} +

Summary input is missing

+ {{else}} +
+
CSV files
{{metrics.csvFiles}}
+
Unique files
{{metrics.uniqueFiles}}
+
Functions
{{metrics.functionsTotal}}
+
Total NLOC
{{metrics.nlocTotal}}
+
Average CCN
{{metrics.averageCcn}}
+
Max CCN
{{metrics.maxCcn}}
+
Average length
{{metrics.averageLength}}
+
Max length
{{metrics.maxLength}}
+
Generated at
{{generatedAt}}
+
-

Top Complex Functions

- - - - - - - - - - - - {{#if topFunctions}} - {{#each topFunctions}} - - - - - - - - {{/each}} - {{else}} - - {{/if}} - -
FunctionFileCCNNLOCLength
{{this.function}}{{this.file}}{{this.ccn}}{{this.nloc}}{{this.length}}
No function metrics available.
+

Top Complex Functions

+ + + + + + + + + + + + {{#if topFunctions}} + {{#each topFunctions}} + + + + + + + + {{/each}} + {{else}} + + {{/if}} + +
FunctionFileCCNNLOCLength
{{this.function}}{{this.file}}{{this.ccn}}{{this.nloc}}{{this.length}}
No function metrics available.
+ {{/if}} From 2d9a8aa5c2634f007b3159aa55b7c0b4cf4a1cd2 Mon Sep 17 00:00:00 2001 From: casianaoprut Date: Wed, 25 Mar 2026 12:58:16 +0200 Subject: [PATCH 05/13] renamed summary output keys in instrument.v2.yml --- instrument.v2.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/instrument.v2.yml b/instrument.v2.yml index a33a295a..add3356e 100644 --- a/instrument.v2.yml +++ b/instrument.v2.yml @@ -15,8 +15,9 @@ actions: python lizard.py -V --csv "${repo}" > "${instrumentPath}/results/${repoName}.csv" summary: - summaryMdFile: results/summary.md - summaryHtmlFile: results/summary.html + md-file: results/summary.md + html-file: results/summary.html + category: Structural Relations commands: generate-summary: id: generate-summary From e51d074f00d929e3a29e968d0cdd26a9581f1dea Mon Sep 17 00:00:00 2001 From: casianaoprut Date: Wed, 25 Mar 2026 14:40:14 +0200 Subject: [PATCH 06/13] removed nested card styling from summary template --- templates/summary.html | 29 ++--------------------------- 1 file changed, 2 insertions(+), 27 deletions(-) diff --git a/templates/summary.html b/templates/summary.html index 10e858fc..6f070d2c 100644 --- a/templates/summary.html +++ b/templates/summary.html @@ -1,41 +1,19 @@ -
+
-

Lizard

-

Status: {{status}}

- {{#if isMissing}}

Summary input is missing

{{else}} From 020575e9b04ea31080d170bd8edc097d152a1565 Mon Sep 17 00:00:00 2001 From: casianaoprut Date: Thu, 26 Mar 2026 12:29:31 +0200 Subject: [PATCH 07/13] formatted numeric values in lizard summary --- summary_extract.py | 59 +++++++++++++++++++++--------------------- templates/summary.html | 18 ++++++------- 2 files changed, 38 insertions(+), 39 deletions(-) diff --git a/summary_extract.py b/summary_extract.py index 3bf59d1a..a1da21c0 100644 --- a/summary_extract.py +++ b/summary_extract.py @@ -244,15 +244,14 @@ def _create_summary_payload( markdown_lines = [ '## Lizard', '', - f'- Status: {status}', - f'- CSV files: {len(csv_files)}', - f'- Unique files analyzed: {len(unique_files)}', - f'- Functions analyzed: {functions_total}', - f'- Total NLOC: {nloc_total}', + f'- CSV files: {_format_int(len(csv_files))}', + f'- Unique files analyzed: {_format_int(len(unique_files))}', + f'- Functions analyzed: {_format_int(functions_total)}', + f'- Total NLOC: {_format_int(nloc_total)}', f'- Average CCN: {average_ccn}', - f'- Max CCN: {max_ccn}', + f'- Max CCN: {_format_int(max_ccn)}', f'- Average length: {average_length}', - f'- Max length: {max_length}', + f'- Max length: {_format_int(max_length)}', '', '### Top Complex Functions', '', @@ -265,25 +264,31 @@ def _create_summary_payload( else: for row in top_functions: markdown_lines.append( - f"| {row.get('function', 'unknown')} | {row.get('file', 'unknown')} | {row.get('ccn', 0)} | " - f"{row.get('nloc', 0)} | {row.get('length', 0)} |" + f"| {row.get('function', 'unknown')} | {row.get('file', 'unknown')} | {_format_int(int(row.get('ccn', 0)))} | " + f"{_format_int(int(row.get('nloc', 0)))} | {_format_int(int(row.get('length', 0)))} |" ) template_model = { - 'status': status, - 'statusClass': _to_status_class(status), 'generatedAt': generated_at, 'metrics': { - 'csvFiles': len(csv_files), - 'uniqueFiles': len(unique_files), - 'functionsTotal': functions_total, - 'nlocTotal': nloc_total, + 'csvFilesFormatted': _format_int(len(csv_files)), + 'uniqueFilesFormatted': _format_int(len(unique_files)), + 'functionsTotalFormatted': _format_int(functions_total), + 'nlocTotalFormatted': _format_int(nloc_total), 'averageCcn': average_ccn, - 'maxCcn': max_ccn, + 'maxCcnFormatted': _format_int(max_ccn), 'averageLength': average_length, - 'maxLength': max_length, + 'maxLengthFormatted': _format_int(max_length), }, - 'topFunctions': top_functions, + 'topFunctions': [ + { + **row, + 'ccnFormatted': _format_int(int(row.get('ccn', 0))), + 'nlocFormatted': _format_int(int(row.get('nloc', 0))), + 'lengthFormatted': _format_int(int(row.get('length', 0))), + } + for row in top_functions + ], } return { @@ -301,8 +306,12 @@ def _format_average(total: int, count: int) -> str: average = float(total) / float(count) if average.is_integer(): - return str(int(average)) - return f'{average:.2f}'.rstrip('0').rstrip('.') + return _format_int(int(average)) + return f'{average:,.2f}'.rstrip('0').rstrip('.') + + +def _format_int(value: int) -> str: + return f'{value:,}' def _resolve_status(csv_count: int, has_data_quality_issues: bool) -> str: @@ -315,13 +324,3 @@ def _resolve_status(csv_count: int, has_data_quality_issues: bool) -> str: def _iso_now() -> str: return datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC') - - -def _to_status_class(status: str) -> str: - if status == 'success': - return 'status-success' - if status == 'partial': - return 'status-warning' - if status == 'failed': - return 'status-error' - return 'status-unknown' diff --git a/templates/summary.html b/templates/summary.html index 6f070d2c..be9b9ac9 100644 --- a/templates/summary.html +++ b/templates/summary.html @@ -41,14 +41,14 @@

Summary input is missing

{{else}}
-
CSV files
{{metrics.csvFiles}}
-
Unique files
{{metrics.uniqueFiles}}
-
Functions
{{metrics.functionsTotal}}
-
Total NLOC
{{metrics.nlocTotal}}
+
CSV files
{{metrics.csvFilesFormatted}}
+
Unique files
{{metrics.uniqueFilesFormatted}}
+
Functions
{{metrics.functionsTotalFormatted}}
+
Total NLOC
{{metrics.nlocTotalFormatted}}
Average CCN
{{metrics.averageCcn}}
-
Max CCN
{{metrics.maxCcn}}
+
Max CCN
{{metrics.maxCcnFormatted}}
Average length
{{metrics.averageLength}}
-
Max length
{{metrics.maxLength}}
+
Max length
{{metrics.maxLengthFormatted}}
Generated at
{{generatedAt}}
@@ -69,9 +69,9 @@

Top Complex Functions

{{this.function}} {{this.file}} - {{this.ccn}} - {{this.nloc}} - {{this.length}} + {{this.ccnFormatted}} + {{this.nlocFormatted}} + {{this.lengthFormatted}} {{/each}} {{else}} From 353dfdfd009eefd9948ccf8622746e33d28e29e9 Mon Sep 17 00:00:00 2001 From: casianaoprut Date: Thu, 26 Mar 2026 12:55:27 +0200 Subject: [PATCH 08/13] formatted summary timestamps with local GMT offsets --- summary_extract.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/summary_extract.py b/summary_extract.py index a1da21c0..eb40f594 100644 --- a/summary_extract.py +++ b/summary_extract.py @@ -2,7 +2,7 @@ import csv from dataclasses import dataclass -from datetime import datetime, timezone +from datetime import datetime from pathlib import Path from typing import Any @@ -323,4 +323,19 @@ def _resolve_status(csv_count: int, has_data_quality_issues: bool) -> str: def _iso_now() -> str: - return datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC') + 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}' From 2d37cba0f4ce3502e897dd26e3b244c1f1023aed Mon Sep 17 00:00:00 2001 From: casianaoprut Date: Thu, 26 Mar 2026 17:47:06 +0200 Subject: [PATCH 09/13] replaced lizard function ranking with technology metrics Aggregate lizard metrics by file extension technology, remove the top complex functions section, and render a metrics-by-technology table in both markdown and html summaries. --- summary_extract.py | 146 ++++++++++++++++++++++++++++------------- templates/summary.html | 32 +++++---- 2 files changed, 118 insertions(+), 60 deletions(-) diff --git a/summary_extract.py b/summary_extract.py index eb40f594..fe944370 100644 --- a/summary_extract.py +++ b/summary_extract.py @@ -16,7 +16,7 @@ class ParsedLizardCsv: length_total: int max_ccn: int max_length: int - top_functions: list[dict[str, Any]] + technology_metrics: dict[str, dict[str, Any]] had_parse_failure: bool invalid_rows: int @@ -36,7 +36,7 @@ def extract_lizard_summary(results_directory: str | Path) -> dict[str, Any]: length_total=0, max_ccn=0, max_length=0, - top_functions=[], + technology_rows=[], has_data_quality_issues=True, ) @@ -56,7 +56,7 @@ def extract_lizard_summary(results_directory: str | Path) -> dict[str, Any]: length_total = 0 max_ccn = 0 max_length = 0 - top_functions: list[dict[str, Any]] = [] + technology_metrics: dict[str, dict[str, Any]] = {} had_parse_failures = False invalid_rows_total = 0 @@ -72,9 +72,9 @@ def extract_lizard_summary(results_directory: str | Path) -> dict[str, Any]: invalid_rows_total += parsed.invalid_rows had_parse_failures = had_parse_failures or parsed.had_parse_failure - top_functions.extend(parsed.top_functions) + _merge_technology_metrics(technology_metrics, parsed.technology_metrics) - top_functions = _pick_top_functions(top_functions, limit=10) + technology_rows = _build_technology_rows(technology_metrics) has_data_quality_issues = had_parse_failures or invalid_rows_total > 0 or functions_total == 0 return _create_summary_payload( @@ -86,7 +86,7 @@ def extract_lizard_summary(results_directory: str | Path) -> dict[str, Any]: length_total=length_total, max_ccn=max_ccn, max_length=max_length, - top_functions=top_functions, + technology_rows=technology_rows, has_data_quality_issues=has_data_quality_issues, ) @@ -99,7 +99,7 @@ def _parse_lizard_csv(file_path: Path) -> ParsedLizardCsv: length_total = 0 max_ccn = 0 max_length = 0 - top_functions: list[dict[str, Any]] = [] + technology_metrics: dict[str, dict[str, Any]] = {} had_parse_failure = False invalid_rows = 0 @@ -117,7 +117,7 @@ def _parse_lizard_csv(file_path: Path) -> ParsedLizardCsv: length_total=0, max_ccn=0, max_length=0, - top_functions=[], + technology_metrics={}, had_parse_failure=True, invalid_rows=0, ) @@ -149,16 +149,27 @@ def _parse_lizard_csv(file_path: Path) -> ParsedLizardCsv: if file_value: unique_files.add(file_value) - top_functions.append( - { - 'function': function_name or 'unknown', - 'file': file_value or 'unknown', - 'location': location_value or 'unknown', - 'ccn': ccn_value, - 'nloc': nloc_value, - 'length': length_value, + technology = _detect_technology(file_value) + if technology not in technology_metrics: + technology_metrics[technology] = { + 'files': set(), + 'functions': 0, + 'nloc_total': 0, + 'ccn_total': 0, + 'length_total': 0, + 'max_ccn': 0, + 'max_length': 0, } - ) + + technology_entry = technology_metrics[technology] + if file_value: + technology_entry['files'].add(file_value) + technology_entry['functions'] += 1 + technology_entry['nloc_total'] += nloc_value + technology_entry['ccn_total'] += ccn_value + technology_entry['length_total'] += length_value + technology_entry['max_ccn'] = max(technology_entry['max_ccn'], ccn_value) + technology_entry['max_length'] = max(technology_entry['max_length'], length_value) except Exception: had_parse_failure = True @@ -170,7 +181,7 @@ def _parse_lizard_csv(file_path: Path) -> ParsedLizardCsv: length_total=length_total, max_ccn=max_ccn, max_length=max_length, - top_functions=_pick_top_functions(top_functions, limit=10), + technology_metrics=technology_metrics, had_parse_failure=had_parse_failure, invalid_rows=invalid_rows, ) @@ -199,17 +210,65 @@ def _resolve_column_indexes(header: list[str] | None) -> dict[str, int] | None: return indexes -def _pick_top_functions(candidates: list[dict[str, Any]], limit: int) -> list[dict[str, Any]]: - ordered = sorted( - candidates, - key=lambda item: ( - -int(item.get('ccn', 0)), - -int(item.get('nloc', 0)), - -int(item.get('length', 0)), - str(item.get('function', '')), - ), - ) - return ordered[:limit] +def _detect_technology(file_path_value: str) -> str: + extension = Path(file_path_value).suffix.lower() + return extension if extension else 'Other' + + +def _merge_technology_metrics( + aggregate: dict[str, dict[str, Any]], + parsed: dict[str, dict[str, Any]], +) -> None: + for technology, values in parsed.items(): + if technology not in aggregate: + aggregate[technology] = { + 'files': set(), + 'functions': 0, + 'nloc_total': 0, + 'ccn_total': 0, + 'length_total': 0, + 'max_ccn': 0, + 'max_length': 0, + } + + target = aggregate[technology] + target['files'].update(values.get('files', set())) + target['functions'] += int(values.get('functions', 0)) + target['nloc_total'] += int(values.get('nloc_total', 0)) + target['ccn_total'] += int(values.get('ccn_total', 0)) + target['length_total'] += int(values.get('length_total', 0)) + target['max_ccn'] = max(int(target['max_ccn']), int(values.get('max_ccn', 0))) + target['max_length'] = max(int(target['max_length']), int(values.get('max_length', 0))) + + +def _build_technology_rows(technology_metrics: dict[str, dict[str, Any]]) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + + for technology, values in technology_metrics.items(): + functions_count = int(values.get('functions', 0)) + nloc_total = int(values.get('nloc_total', 0)) + ccn_total = int(values.get('ccn_total', 0)) + length_total = int(values.get('length_total', 0)) + max_ccn = int(values.get('max_ccn', 0)) + max_length = int(values.get('max_length', 0)) + files_count = len(values.get('files', set())) + + rows.append( + { + 'technology': technology, + 'filesFormatted': _format_int(files_count), + 'functionsFormatted': _format_int(functions_count), + 'nlocFormatted': _format_int(nloc_total), + 'averageCcn': _format_average(ccn_total, functions_count), + 'maxCcnFormatted': _format_int(max_ccn), + 'averageLength': _format_average(length_total, functions_count), + 'maxLengthFormatted': _format_int(max_length), + 'nlocRaw': nloc_total, + } + ) + + rows.sort(key=lambda row: (-int(row['nlocRaw']), str(row['technology']).lower())) + return rows def _create_summary_payload( @@ -221,7 +280,7 @@ def _create_summary_payload( length_total: int, max_ccn: int, max_length: int, - top_functions: list[dict[str, Any]], + technology_rows: list[dict[str, Any]], has_data_quality_issues: bool, ) -> dict[str, Any]: generated_at = _iso_now() @@ -253,19 +312,20 @@ def _create_summary_payload( f'- Average length: {average_length}', f'- Max length: {_format_int(max_length)}', '', - '### Top Complex Functions', + '### Metrics by Technology', '', - '| Function | File | CCN | NLOC | Length |', - '| --- | --- | ---: | ---: | ---: |', + '| Technology | Files | Functions | Total NLOC | Avg CCN | Max CCN | Avg Length | Max Length |', + '| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |', ] - if not top_functions: - markdown_lines.append('| _none_ | _none_ | 0 | 0 | 0 |') + if not technology_rows: + markdown_lines.append('| _none_ | 0 | 0 | 0 | 0 | 0 | 0 | 0 |') else: - for row in top_functions: + for row in technology_rows: markdown_lines.append( - f"| {row.get('function', 'unknown')} | {row.get('file', 'unknown')} | {_format_int(int(row.get('ccn', 0)))} | " - f"{_format_int(int(row.get('nloc', 0)))} | {_format_int(int(row.get('length', 0)))} |" + f"| {row.get('technology', 'Other')} | {row.get('filesFormatted', '0')} | {row.get('functionsFormatted', '0')} | " + f"{row.get('nlocFormatted', '0')} | {row.get('averageCcn', '0')} | {row.get('maxCcnFormatted', '0')} | " + f"{row.get('averageLength', '0')} | {row.get('maxLengthFormatted', '0')} |" ) template_model = { @@ -280,15 +340,7 @@ def _create_summary_payload( 'averageLength': average_length, 'maxLengthFormatted': _format_int(max_length), }, - 'topFunctions': [ - { - **row, - 'ccnFormatted': _format_int(int(row.get('ccn', 0))), - 'nlocFormatted': _format_int(int(row.get('nloc', 0))), - 'lengthFormatted': _format_int(int(row.get('length', 0))), - } - for row in top_functions - ], + 'technologyRows': technology_rows, } return { diff --git a/templates/summary.html b/templates/summary.html index be9b9ac9..dd3a03b7 100644 --- a/templates/summary.html +++ b/templates/summary.html @@ -52,30 +52,36 @@
Generated at
{{generatedAt}}
-

Top Complex Functions

+

Metrics by Technology

- - - - - + + + + + + + + - {{#if topFunctions}} - {{#each topFunctions}} + {{#if technologyRows}} + {{#each technologyRows}} - - - + + + - + + + + {{/each}} {{else}} - + {{/if}}
FunctionFileCCNNLOCLengthTechnologyFilesFunctionsTotal NLOCAvg CCNMax CCNAvg LengthMax Length
{{this.function}}{{this.file}}{{this.ccnFormatted}}{{this.technology}}{{this.filesFormatted}}{{this.functionsFormatted}} {{this.nlocFormatted}}{{this.lengthFormatted}}{{this.averageCcn}}{{this.maxCcnFormatted}}{{this.averageLength}}{{this.maxLengthFormatted}}
No function metrics available.
No technology metrics available.
From 72b1e5acbe5d11098b5219885bcfb5a2e6f55c43 Mon Sep 17 00:00:00 2001 From: casianaoprut Date: Thu, 26 Mar 2026 18:42:24 +0200 Subject: [PATCH 10/13] remove missing status fields from fallback summary payload --- lizard-summary.py | 3 --- summary_render.py | 2 +- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/lizard-summary.py b/lizard-summary.py index 6ec4e527..1672e83a 100644 --- a/lizard-summary.py +++ b/lizard-summary.py @@ -17,12 +17,9 @@ def build_missing_payload() -> dict[str, object]: 'markdown': '\n'.join([ '## Lizard', '', - '- Status: missing', '- Summary input is missing', ]), 'templateModel': { - 'status': 'missing', - 'statusClass': 'status-missing', 'isMissing': True, }, } diff --git a/summary_render.py b/summary_render.py index 60455c8f..899f668a 100644 --- a/summary_render.py +++ b/summary_render.py @@ -6,7 +6,7 @@ DEFAULT_TEMPLATE_PATH = Path(__file__).resolve().parent / 'templates' / 'summary.html' -FALLBACK_TEMPLATE = '

{{tool}}

Status: {{status}}

' +FALLBACK_TEMPLATE = '

{{tool}}

' def render_summary( From 89d72564722030fa7b838647b91f4dfb7189779e Mon Sep 17 00:00:00 2001 From: casianaoprut Date: Fri, 3 Apr 2026 16:37:47 +0300 Subject: [PATCH 11/13] update instrument.v2.yml --- instrument.v2.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/instrument.v2.yml b/instrument.v2.yml index add3356e..ab85cf95 100644 --- a/instrument.v2.yml +++ b/instrument.v2.yml @@ -1,6 +1,6 @@ -name: lizard +name: Lizard id: lizard -version: 1.0.0 +version: 1.19.2 actions: start: @@ -26,6 +26,11 @@ actions: windows: py -3 lizard-summary.py "${instrumentPath}/results" unix: python3 lizard-summary.py "${instrumentPath}/results" + clean: + with: + locations: + - source: ${instrumentDir}/results + pack: with: locations: From 168db271b1a83b5c68c6df189da15a0045c8c13e Mon Sep 17 00:00:00 2001 From: casianaoprut Date: Tue, 7 Apr 2026 17:15:40 +0300 Subject: [PATCH 12/13] use lizard language mapping and simplify summary output --- summary_extract.py | 96 +++++++++++++++++++++++++++--------------- templates/summary.html | 4 +- 2 files changed, 62 insertions(+), 38 deletions(-) diff --git a/summary_extract.py b/summary_extract.py index fe944370..34ffb3a0 100644 --- a/summary_extract.py +++ b/summary_extract.py @@ -2,10 +2,11 @@ import csv from dataclasses import dataclass -from datetime import datetime from pathlib import Path from typing import Any +from lizard_languages import get_reader_for + @dataclass(frozen=True) class ParsedLizardCsv: @@ -211,8 +212,54 @@ def _resolve_column_indexes(header: list[str] | None) -> dict[str, int] | None: def _detect_technology(file_path_value: str) -> str: - extension = Path(file_path_value).suffix.lower() - return extension if extension else 'Other' + if not file_path_value: + return 'Other' + + reader = get_reader_for(file_path_value) + if reader is None: + return 'Other' + + language_names = getattr(reader, 'language_names', []) + if not language_names: + return 'Other' + + return _format_language_name(str(language_names[0])) + + +def _format_language_name(language_name: str) -> str: + normalized = language_name.strip().lower() + + aliases = { + 'cpp': 'C/C++', + 'c': 'C/C++', + 'csharp': 'C#', + 'javascript': 'JavaScript', + 'js': 'JavaScript', + 'typescript': 'TypeScript', + 'objectivec': 'Objective-C', + 'objective-c': 'Objective-C', + 'objc': 'Objective-C', + 'gdscript': 'GDScript', + 'go': 'Go', + 'java': 'Java', + 'kotlin': 'Kotlin', + 'python': 'Python', + 'php': 'PHP', + 'ruby': 'Ruby', + 'swift': 'Swift', + 'scala': 'Scala', + 'rust': 'Rust', + 'lua': 'Lua', + 'fortran': 'Fortran', + 'tnsdl': 'TNSDL', + 'ttcn': 'TTCN', + 'ttcn3': 'TTCN', + } + + if normalized in aliases: + return aliases[normalized] + + return language_name.strip() or 'Other' def _merge_technology_metrics( @@ -283,13 +330,11 @@ def _create_summary_payload( technology_rows: list[dict[str, Any]], has_data_quality_issues: bool, ) -> dict[str, Any]: - generated_at = _iso_now() average_ccn = _format_average(ccn_total, functions_total) average_length = _format_average(length_total, functions_total) status = _resolve_status(csv_count=len(csv_files), has_data_quality_issues=has_data_quality_issues) metadata = { - 'metadata.csv.files': len(csv_files), 'metadata.files.unique': len(unique_files), 'metadata.functions.total': functions_total, 'metadata.nloc.total': nloc_total, @@ -297,20 +342,22 @@ def _create_summary_payload( 'metadata.ccn.max': max_ccn, 'metadata.length.average': average_length, 'metadata.length.max': max_length, - 'metadata.generated.at': generated_at, } markdown_lines = [ '## Lizard', '', - f'- CSV files: {_format_int(len(csv_files))}', - f'- Unique files analyzed: {_format_int(len(unique_files))}', - f'- Functions analyzed: {_format_int(functions_total)}', - f'- Total NLOC: {_format_int(nloc_total)}', - f'- Average CCN: {average_ccn}', - f'- Max CCN: {_format_int(max_ccn)}', - f'- Average length: {average_length}', - f'- Max length: {_format_int(max_length)}', + ( + f'- NLOC: {_format_int(nloc_total)} / ' + f'Unique files: {_format_int(len(unique_files))} / ' + f'Functions: {_format_int(functions_total)}' + ), + ( + f'- Average CCN: {average_ccn} / ' + f'Max CCN: {_format_int(max_ccn)} / ' + f'Average length: {average_length} / ' + f'Max length: {_format_int(max_length)}' + ), '', '### Metrics by Technology', '', @@ -329,9 +376,7 @@ def _create_summary_payload( ) template_model = { - 'generatedAt': generated_at, 'metrics': { - 'csvFilesFormatted': _format_int(len(csv_files)), 'uniqueFilesFormatted': _format_int(len(unique_files)), 'functionsTotalFormatted': _format_int(functions_total), 'nlocTotalFormatted': _format_int(nloc_total), @@ -372,22 +417,3 @@ def _resolve_status(csv_count: int, has_data_quality_issues: bool) -> str: if has_data_quality_issues: return 'partial' return 'success' - - -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 dd3a03b7..88f2dfa5 100644 --- a/templates/summary.html +++ b/templates/summary.html @@ -41,15 +41,13 @@

Summary input is missing

{{else}}
-
CSV files
{{metrics.csvFilesFormatted}}
+
Total NLOC
{{metrics.nlocTotalFormatted}}
Unique files
{{metrics.uniqueFilesFormatted}}
Functions
{{metrics.functionsTotalFormatted}}
-
Total NLOC
{{metrics.nlocTotalFormatted}}
Average CCN
{{metrics.averageCcn}}
Max CCN
{{metrics.maxCcnFormatted}}
Average length
{{metrics.averageLength}}
Max length
{{metrics.maxLengthFormatted}}
-
Generated at
{{generatedAt}}

Metrics by Technology

From 52be28a5fec4e38a4378c8ccdd25692cd8c9213a Mon Sep 17 00:00:00 2001 From: casianaoprut Date: Thu, 9 Apr 2026 14:06:51 +0300 Subject: [PATCH 13/13] rebalance summary chips for consistent two-row layout --- templates/summary.html | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/templates/summary.html b/templates/summary.html index 88f2dfa5..5a156ea2 100644 --- a/templates/summary.html +++ b/templates/summary.html @@ -22,6 +22,10 @@ border-radius: 6px; } + .lizard-summary .summary-item.placeholder { + visibility: hidden; + } + .lizard-summary table { width: 100%; border-collapse: collapse; @@ -44,6 +48,7 @@
Total NLOC
{{metrics.nlocTotalFormatted}}
Unique files
{{metrics.uniqueFilesFormatted}}
Functions
{{metrics.functionsTotalFormatted}}
+
Average CCN
{{metrics.averageCcn}}
Max CCN
{{metrics.maxCcnFormatted}}
Average length
{{metrics.averageLength}}