From 4de91eb16d842d7c8ed1fa171a76fa7d2866d23c Mon Sep 17 00:00:00 2001 From: pcvantol Date: Wed, 29 Jul 2026 17:28:29 +0200 Subject: [PATCH] Fix Lizard complexity evidence normalization --- pyproject.toml | 2 +- src/tde_cli/main.py | 2 +- src/tde_runtime/complexity.py | 27 ++++++++++++++---- src/tde_runtime/policies/generation-1.json | 2 +- src/tde_runtime/registries.py | 6 ++-- src/tde_runtime/runtime.py | 2 +- tests/test_cli.py | 4 +-- tests/test_complexity.py | 32 ++++++++++++++++++++++ tests/test_coverage.py | 2 +- tests/test_dependency_health.py | 2 +- tests/test_public_policy_configuration.py | 4 +-- tests/test_runtime.py | 2 +- 12 files changed, 67 insertions(+), 20 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index da42834..f13b788 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "technical-debt-engine-runtime" -version = "1.1.0" +version = "1.1.1" description = "Technical Debt Engine runtime foundation API" requires-python = ">=3.11" dependencies = ["lizard==1.23.0", "radon==6.0.1"] diff --git a/src/tde_cli/main.py b/src/tde_cli/main.py index 8447810..8bca478 100644 --- a/src/tde_cli/main.py +++ b/src/tde_cli/main.py @@ -29,7 +29,7 @@ from tde_runtime.differential import AssessmentBaselineRegistry, DifferentialEngine, DifferentialError -CLI_VERSION = "1.1.0" +CLI_VERSION = "1.1.1" GENERATION = "1" diff --git a/src/tde_runtime/complexity.py b/src/tde_runtime/complexity.py index 1de6099..54ac7b0 100644 --- a/src/tde_runtime/complexity.py +++ b/src/tde_runtime/complexity.py @@ -17,11 +17,11 @@ from .source_classification import classification, language_for, primary_languages CAPABILITY_ID = "complexity" -CAPABILITY_VERSION = "1.1.0" +CAPABILITY_VERSION = "1.1.1" RADON_ADAPTER_ID = "complexity.radon" LIZARD_ADAPTER_ID = "complexity.lizard" ADAPTER_ID = RADON_ADAPTER_ID # Compatibility import for existing Python consumers. -ADAPTER_VERSION = "1.1.0" +ADAPTER_VERSION = "1.1.1" MINIMUM_RADON_VERSION = (6, 0) MINIMUM_LIZARD_VERSION = (1, 23) LIZARD_LANGUAGES = {"JavaScript": "javascript", "TypeScript": "typescript", "Swift": "swift", "C": "cpp", "C++": "cpp", "C#": "csharp"} @@ -154,6 +154,12 @@ def _lizard(root: Path, paths: list[Path], languages: tuple[str, ...], timeout: except (ValueError, IndexError): return {"status": "INVALID_EVIDENCE", "limitations": [{"id": "complexity.lizard.malformed_output", "description": "Lizard CSV contained invalid symbol data.", "cause": "invalid analyzer evidence"}]} language = language_for(path) + # Lizard emits a file-level ``*global*`` row at source line zero for + # some C# files. It is not a function or method measurement and has + # no canonical symbol location, so it must not participate in the + # symbol contract. + if name == "*global*" and line == 0: + continue if not name or not path or language not in languages or line < 1: return {"status": "INVALID_EVIDENCE", "limitations": [{"id": "complexity.lizard.location_missing", "description": "Lizard omitted a required source location or language.", "cause": "invalid analyzer evidence"}]} symbols.append({"path": path, "classification": classification(path), "language": language, "name": name, @@ -196,10 +202,19 @@ def analyze(root: Path, timeout: int = 60, configuration: Mapping[str, Any] | No adapters.append(result["adapter"]) ignored_symbols = set(_items(configuration.get("ignoredSymbols"))) symbols = [symbol for symbol in symbols if symbol["name"] not in ignored_symbols] - identities = [(symbol["path"], symbol["name"], symbol["line"], symbol["adapterId"]) for symbol in symbols] - if len(identities) != len(set(identities)): - return {"status": "INVALID_EVIDENCE", "symbols": symbols, "adapters": adapters, "thresholds": thresholds, - "primaryLanguages": list(primary), "limitations": [{"id": "complexity.symbol.duplicate", "description": "Analyzer results contain duplicate symbol identities.", "cause": "conflicting analyzer evidence"}]} + canonical_symbols: dict[tuple[str, str, int, str], dict[str, Any]] = {} + for symbol in symbols: + identity = (symbol["path"], symbol["name"], symbol["line"], symbol["adapterId"]) + existing = canonical_symbols.get(identity) + if existing is None: + canonical_symbols[identity] = symbol + elif existing != symbol: + return {"status": "INVALID_EVIDENCE", "symbols": symbols, "adapters": adapters, "thresholds": thresholds, + "primaryLanguages": list(primary), "limitations": [{"id": "complexity.symbol.duplicate", "description": "Analyzer results contain conflicting symbol identities.", "cause": "conflicting analyzer evidence"}]} + # Some Lizard language readers emit an identical row twice. Preserve the + # raw analyzer output in provenance, while normalising the canonical + # symbol set to one measurement per identity. + symbols = list(canonical_symbols.values()) symbols.sort(key=lambda item: (item["language"], item["path"], item["line"], item["name"])) return {"status": "VALID", "symbols": symbols, "adapters": adapters, "thresholds": thresholds, "primaryLanguages": list(primary), "limitations": limitations} diff --git a/src/tde_runtime/policies/generation-1.json b/src/tde_runtime/policies/generation-1.json index 1376e50..74f29d6 100644 --- a/src/tde_runtime/policies/generation-1.json +++ b/src/tde_runtime/policies/generation-1.json @@ -6,7 +6,7 @@ "description": "Default, language-independent Generation 1 qualification policy.", "supportedCapabilities": ["code_size", "complexity", "coverage", "dependency_health"], "supportedSchemas": ["1.0.0"], - "supportedRuntimeVersions": ["0.2.0", "1.0.0rc1", "1.0.0rc2", "1.0.0rc3", "1.0.0", "1.0.1", "1.0.2", "1.0.3", "1.0.4", "1.0.5", "1.1.0"], + "supportedRuntimeVersions": ["0.2.0", "1.0.0rc1", "1.0.0rc2", "1.0.0rc3", "1.0.0", "1.0.1", "1.0.2", "1.0.3", "1.0.4", "1.0.5", "1.1.0", "1.1.1"], "rules": [ {"id": "code_size.repository_lines", "type": "threshold", "capability": "code_size", "metric": "code_size.source_lines", "operator": "greater_than", "threshold": {"warning": 50000, "blocking": 75000}, "severity": {"warning": "WARNING", "blocking": "BLOCKING"}, "enabled": true, "rationale": "Repository-size decisions measure product source only. Tests, documentation and configuration remain visible as evidence but do not inflate the source-code threshold."}, {"id": "complexity.product.maximum", "type": "threshold", "capability": "complexity", "metric": "complexity.cyclomatic.product.maximum", "operator": "greater_than", "threshold": {"warning": 15, "blocking": 30}, "severity": {"warning": "WARNING", "blocking": "BLOCKING"}, "enabled": true, "rationale": "The blocking complexity gate applies only to production source. Test, fixture and verification complexity remains canonical evidence but does not redefine the production-source outcome."}, diff --git a/src/tde_runtime/registries.py b/src/tde_runtime/registries.py index aad664d..588a6a2 100644 --- a/src/tde_runtime/registries.py +++ b/src/tde_runtime/registries.py @@ -12,7 +12,7 @@ class CapabilityRegistry: def discover(self) -> tuple[object, ...]: return ( {"id": "code_size", "version": "0.1.0", "description": "Canonical physical code-size metrics", "outputContract": "tde.code_size.v1", "analyzerSelection": "highest_priority", "qualificationRules": "complete_adapter_evidence", "supportedAnalyzers": ("code_size.cloc",)}, - {"id": "complexity", "version": "1.1.0", "description": "Canonical cross-language cyclomatic-complexity metrics", "outputContract": "tde.complexity.v1", "analyzerSelection": "repository_primary_language", "qualificationRules": "complete_adapter_evidence", "supportedAnalyzers": ("complexity.radon", "complexity.lizard")}, + {"id": "complexity", "version": "1.1.1", "description": "Canonical cross-language cyclomatic-complexity metrics", "outputContract": "tde.complexity.v1", "analyzerSelection": "repository_primary_language", "qualificationRules": "complete_adapter_evidence", "supportedAnalyzers": ("complexity.radon", "complexity.lizard")}, {"id": "coverage", "version": "0.1.0", "description": "Canonical test coverage metrics from existing artifacts", "outputContract": "tde.coverage.v1", "analyzerSelection": "highest_priority", "qualificationRules": "complete_adapter_evidence", "supportedAnalyzers": ("coverage.artifact",)}, {"id":"maintainability","version":"0.1.0","status":"VALIDATED"}, {"id":"dependency_health","version":"1.0.0","description":"Canonical DJConnect dependency-health evidence", "outputContract":"tde.dependency_health.v1", "analyzerSelection":"highest_priority", "qualificationRules":"complete_adapter_evidence", "supportedAnalyzers":("dependency_health.platform",)}, @@ -23,8 +23,8 @@ class AdapterRegistry: def discover(self) -> tuple[object, ...]: return ( {"id": "code_size.cloc", "version": "0.1.0", "analyzer": "cloc", "capabilities": ("code_size",), "minimumVersion": "2.10", "platforms": ("any",), "priority": 100}, - {"id":"complexity.radon","version":"1.1.0","analyzer":"radon", "capabilities": ("complexity",), "minimumVersion": "6.0", "platforms": ("any",), "languages": ("Python",), "priority": 100}, - {"id":"complexity.lizard","version":"1.1.0","analyzer":"lizard", "capabilities": ("complexity",), "minimumVersion": "1.23", "platforms": ("any",), "languages": ("JavaScript", "TypeScript", "Swift", "C", "C++", "C#"), "priority": 90}, + {"id":"complexity.radon","version":"1.1.1","analyzer":"radon", "capabilities": ("complexity",), "minimumVersion": "6.0", "platforms": ("any",), "languages": ("Python",), "priority": 100}, + {"id":"complexity.lizard","version":"1.1.1","analyzer":"lizard", "capabilities": ("complexity",), "minimumVersion": "1.23", "platforms": ("any",), "languages": ("JavaScript", "TypeScript", "Swift", "C", "C++", "C#"), "priority": 90}, {"id":"coverage.artifact","version":"0.1.0","analyzer":"coverage-artifact", "capabilities": ("coverage",), "minimumVersion": "1.0", "platforms": ("any",), "priority": 100}, {"id":"dependency_health.platform","version":"1.0.0","analyzer":"consumer-native", "capabilities": ("dependency_health",), "minimumVersion": "1.0", "platforms": ("any",), "priority": 100}, ) diff --git a/src/tde_runtime/runtime.py b/src/tde_runtime/runtime.py index c4e4ac1..8e86063 100644 --- a/src/tde_runtime/runtime.py +++ b/src/tde_runtime/runtime.py @@ -20,7 +20,7 @@ from .runtime_qualification import RuntimeQualificationEngine from .schemas import SchemaRegistry -RUNTIME_VERSION = "1.1.0" +RUNTIME_VERSION = "1.1.1" EVIDENCE_SCHEMA_VERSION = "1.0.0" diff --git a/tests/test_cli.py b/tests/test_cli.py index fe0ee5a..49cc68a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -41,8 +41,8 @@ def test_version_includes_cli_runtime_schema_and_generation(self) -> None: code, output = self.invoke("--format", "json", "--version") self.assertEqual(ExitCode.SUCCESS, code) version = json.loads(output) - self.assertEqual("1.1.0", version["cliVersion"]) - self.assertEqual("1.1.0", version["runtimeVersion"]) + self.assertEqual("1.1.1", version["cliVersion"]) + self.assertEqual("1.1.1", version["runtimeVersion"]) self.assertEqual("1.0.0", version["schemaVersion"]) self.assertEqual("1", version["generation"]) diff --git a/tests/test_complexity.py b/tests/test_complexity.py index 3620c4e..e454ee0 100644 --- a/tests/test_complexity.py +++ b/tests/test_complexity.py @@ -103,6 +103,38 @@ def test_lizard_normalizes_typescript_symbols_with_provenance(self): self.assertEqual("complexity.lizard", result["symbols"][0]["adapterId"]) self.assertEqual("lizard==1.23.0", result["adapter"]["analyzer"]["package"]) + def test_lizard_ignores_synthetic_csharp_file_statistics(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + target = root / "Program.cs" + target.write_text("class Program {}\n", encoding="utf-8") + native = ( + f'116,1,117,0,1372,"*global*@0-1371@{target}","{target}",*global*,*global*,0,1371\n' + f'4,2,20,0,5,"Branch@2-6@{target}","{target}",Branch,Branch(),2,6\n' + ) + with patch("tde_runtime.complexity.discover", return_value={"status": "VALID", "executable": "lizard", "version": "1.23.0"}), \ + patch("tde_runtime.complexity.subprocess.run") as run: + run.return_value.stdout = native + result = _lizard(root, [target], ("C#",), 10) + self.assertEqual("VALID", result["status"]) + self.assertEqual(["Branch"], [symbol["name"] for symbol in result["symbols"]]) + + def test_identical_analyzer_symbols_are_normalized_but_conflicts_block(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "Program.cs").write_text("class Program {}\n", encoding="utf-8") + base = {"path": "Program.cs", "classification": "PRODUCT_SOURCE", "language": "C#", "name": "Branch", "type": "function", "line": 2, "endLine": 6, "complexity": 2, "adapterId": "complexity.lizard", "toolId": "lizard"} + adapter = {"id": "complexity.lizard", "version": "1.1.1", "analyzer": {"id": "lizard", "version": "1.23.0"}, "rawOutput": "", "rawOutputHash": "sha256:test"} + with patch("tde_runtime.complexity._lizard", return_value={"status": "VALID", "symbols": [base, dict(base)], "adapter": adapter}): + valid = analyze(root) + self.assertEqual("VALID", valid["status"]) + self.assertEqual(1, len(valid["symbols"])) + conflicting = dict(base, complexity=3) + with patch("tde_runtime.complexity._lizard", return_value={"status": "VALID", "symbols": [base, conflicting], "adapter": adapter}): + invalid = analyze(root) + self.assertEqual("INVALID_EVIDENCE", invalid["status"]) + self.assertEqual("complexity.symbol.duplicate", invalid["limitations"][0]["id"]) + def test_primary_language_prevents_auxiliary_python_from_qualifying_csharp(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) diff --git a/tests/test_coverage.py b/tests/test_coverage.py index c7766f5..0e2ccf8 100644 --- a/tests/test_coverage.py +++ b/tests/test_coverage.py @@ -101,7 +101,7 @@ def test_corrupt_coverage_fails_closed(self) -> None: def test_policy_threshold_uses_coverage_without_runtime_specific_policy_logic(self) -> None: (self.root / "coverage.xml").write_text(COBERTURA, encoding="utf-8") - policy = {"identifier": "coverage-policy", "version": "1.0.0", "scope": "repository", "owner": "tests", "description": "coverage threshold", "supportedCapabilities": ["coverage"], "supportedSchemas": ["1.0.0"], "supportedRuntimeVersions": ["1.1.0"], "rules": [{"id": "minimum-line-coverage", "type": "threshold", "capability": "coverage", "metric": "coverage.line_coverage", "operator": "less_than", "threshold": {"warning": 80, "blocking": 60}, "severity": {"warning": "WARNING", "blocking": "BLOCKING"}, "enabled": True, "rationale": "coverage floor"}]} + policy = {"identifier": "coverage-policy", "version": "1.0.0", "scope": "repository", "owner": "tests", "description": "coverage threshold", "supportedCapabilities": ["coverage"], "supportedSchemas": ["1.0.0"], "supportedRuntimeVersions": ["1.1.1"], "rules": [{"id": "minimum-line-coverage", "type": "threshold", "capability": "coverage", "metric": "coverage.line_coverage", "operator": "less_than", "threshold": {"warning": 80, "blocking": 60}, "severity": {"warning": "WARNING", "blocking": "BLOCKING"}, "enabled": True, "rationale": "coverage floor"}]} path = self.root / "policy.json"; path.write_text(json.dumps(policy), encoding="utf-8") code, result = self.invoke("--policy", str(path), "assess", "--capability", "coverage", str(self.root)) self.assertEqual(ExitCode.FAILED, code) diff --git a/tests/test_dependency_health.py b/tests/test_dependency_health.py index e14d635..386c3ab 100644 --- a/tests/test_dependency_health.py +++ b/tests/test_dependency_health.py @@ -71,7 +71,7 @@ def test_unsupported_repository_remains_valid_with_unavailable_evidence(self) -> def test_unknown_dependency_and_policy_are_evaluated(self) -> None: self.project(missing=True); self.npm({}) - policy = {"identifier": "dependency-policy", "version": "1.0.0", "scope": "repository", "owner": "tests", "description": "dependency threshold", "supportedCapabilities": ["dependency_health"], "supportedSchemas": ["1.0.0"], "supportedRuntimeVersions": ["1.1.0"], "rules": [{"id": "unknown", "type": "threshold", "capability": "dependency_health", "metric": "dependency_health.unknown_dependencies", "operator": "greater_than", "threshold": {"warning": 1, "blocking": 1}, "severity": {"warning": "WARNING", "blocking": "BLOCKING"}, "enabled": True, "rationale": "unknown dependencies"}]} + policy = {"identifier": "dependency-policy", "version": "1.0.0", "scope": "repository", "owner": "tests", "description": "dependency threshold", "supportedCapabilities": ["dependency_health"], "supportedSchemas": ["1.0.0"], "supportedRuntimeVersions": ["1.1.1"], "rules": [{"id": "unknown", "type": "threshold", "capability": "dependency_health", "metric": "dependency_health.unknown_dependencies", "operator": "greater_than", "threshold": {"warning": 1, "blocking": 1}, "severity": {"warning": "WARNING", "blocking": "BLOCKING"}, "enabled": True, "rationale": "unknown dependencies"}]} path = self.root / "policy.json"; path.write_text(json.dumps(policy), encoding="utf-8") code, result = self.invoke("--policy", str(path), "assess", "--capability", "dependency_health", str(self.root)) self.assertEqual(ExitCode.FAILED, code) diff --git a/tests/test_public_policy_configuration.py b/tests/test_public_policy_configuration.py index 108213d..f6113e9 100644 --- a/tests/test_public_policy_configuration.py +++ b/tests/test_public_policy_configuration.py @@ -51,7 +51,7 @@ def configuration(threshold: int) -> dict[str, object]: "identifier": "example.code-size", "version": "2026.1", "scope": "repository", "owner": "example", "description": "Example organization policy.", "supportedCapabilities": ["code_size"], "supportedSchemas": ["1.0.0"], - "supportedRuntimeVersions": ["1.1.0"], + "supportedRuntimeVersions": ["1.1.1"], "rules": [{"id": "example.code-size.lines", "type": "threshold", "capability": "code_size", "metric": "code_size.code_lines", "operator": "greater_than", "threshold": {"warning": threshold, "blocking": threshold + 1000}, @@ -93,7 +93,7 @@ def test_installed_wheel_publishes_and_enforces_the_schema_contract(self) -> Non *evidence["assessment"]["capabilityExecutions"]]: self.assertEqual("1.0.0", item["schema"]["version"]) self.assertEqual("1", item["schema"]["compatibilityVersion"]) - self.assertEqual("1.1.0", item["schema"]["runtimeVersion"]) + self.assertEqual("1.1.1", item["schema"]["runtimeVersion"]) record = next((location / "evidence").glob("*.json")) persisted = json.loads(record.read_text(encoding="utf-8")) persisted["evidence"]["policyEvidence"]["schema"]["version"] = "999.0.0" diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 3cdb392..d0df05d 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -61,7 +61,7 @@ def test_policy_override_can_block_a_measurement(self) -> None: def test_context_contains_canonical_runtime_values(self) -> None: result = Runtime().execute(self.root) - self.assertEqual("1.1.0", result.context.runtime_version) + self.assertEqual("1.1.1", result.context.runtime_version) self.assertEqual("1.0.0", result.context.schema_version) self.assertTrue(result.context.execution_id.startswith("execution.")) self.assertEqual("content_digest", result.context.candidate["identityType"])