From 715a1b0dff48bd148cab6735e070368db6cb16a9 Mon Sep 17 00:00:00 2001 From: pcvantol Date: Wed, 29 Jul 2026 09:47:01 +0200 Subject: [PATCH 1/3] Add cross-language complexity parity --- ENGINEERING_STATUS.md | 14 +- INTEGRATION_MODEL.md | 9 + PRODUCT_BACKLOG.md | 1 + PRODUCT_ROADMAP.md | 5 +- README.md | 28 +-- ...cross-language-complexity-policy-parity.md | 89 ++++++++ docs/assessment-profiles.md | 7 + docs/assessment-schema.md | 6 + docs/complexity-support-matrix.md | 29 +++ pyproject.toml | 4 +- src/tde_cli/main.py | 2 +- src/tde_runtime/analyzer_discovery.py | 12 ++ src/tde_runtime/code_size.py | 38 +++- src/tde_runtime/complexity.py | 203 +++++++++++++----- src/tde_runtime/execution.py | 55 +++-- src/tde_runtime/policies/generation-1.json | 2 +- src/tde_runtime/registries.py | 5 +- src/tde_runtime/runtime.py | 2 +- src/tde_runtime/source_classification.py | 103 +++++++++ tests/test_cli.py | 4 +- tests/test_code_size.py | 6 + tests/test_complexity.py | 50 ++++- tests/test_coverage.py | 2 +- tests/test_dependency_health.py | 2 +- tests/test_public_policy_configuration.py | 4 +- tests/test_runtime.py | 2 +- 26 files changed, 566 insertions(+), 118 deletions(-) create mode 100644 architecture/adr/ADR-0065-cross-language-complexity-policy-parity.md create mode 100644 docs/complexity-support-matrix.md create mode 100644 src/tde_runtime/source_classification.py diff --git a/ENGINEERING_STATUS.md b/ENGINEERING_STATUS.md index b4762a0..0d009c3 100644 --- a/ENGINEERING_STATUS.md +++ b/ENGINEERING_STATUS.md @@ -2,16 +2,16 @@ | Field | Current state | | --- | --- | -| Current program | Generation 2 — TDE 1.0 DJConnect Enablement Program | -| Lifecycle state | `RC2_CANDIDATE_PREPARATION_REVIEWABLE_FROZEN`; G2-A, G2-B, and G2-C are complete and the remaining 1.0 work is deliberately bounded. | -| Current branch | `codex/rc2-candidate-preparation` | -| Current decision | `TDE_1_0_0_RC2_CANDIDATE_PREPARATION_READY` | -| Repository truth | `0.2.0` is published and qualified. Generation 1 is complete. G2-A completed public CLI, baseline/differential, and qualification proof, then validated fresh post-merge branch-coverage artifacts from `djconnect` and `djconnect-pi`; TDE does not run tests or generate coverage. G2-B covers the eight active DJConnect repositories with native pip, npm, NuGet, SwiftPM, and PlatformIO evidence where applicable. G2-D has three successful `djconnect-pi` Observe runs using the exact public `0.2.0` CLI; the retained artifacts and limitations are recorded in [the Phase 1 Observe record](docs/djconnect-observe-pilot.md). | +| Current program | TDE 1.1 — Cross-language Complexity Policy Parity | +| Lifecycle state | `IMPLEMENTATION_REVIEWABLE`; TDE 1.0.5 and all seven Observe-only integrations are complete. | +| Current branch | `codex/tde-1-1-complexity-parity` | +| Current decision | `ADR-0065_CROSS_LANGUAGE_COMPLEXITY_POLICY_PARITY` | +| Repository truth | `1.0.5` is the current published runtime. The authorized 1.1 increment retains the four capability public contract and adds canonical primary-language complexity adapters without changing thresholds, qualification semantics, or Observe-only governance. | | Coverage baseline | `djconnect`: 87.73% lines and 68.91% branches; `djconnect-pi`: 75.10% lines and 62.38% branches; `djconnect-website`: 96.59% lines and 82.86% branches; `djconnect-esp32`: 88.83% lines and 60.53% branches. Each fresh G2-A completion artifact is `VALID` with `QUALIFIED` runtime. | | Dependency-health baseline | All eight repositories produced valid, qualified capability evidence. Current outdated findings: `djconnect` 3, `djconnect-api` 4, `djconnect-website` 1, `djconnect-windows` 15, `djconnect-esp32` 1, and `djconnect-app` 0; `djconnect-pi` is explicitly unavailable because its direct requirements are unpinned; `djconnect-firmware` has no supported manifest. | -| Next planned public release | `1.0.0`, after the immutable `1.0.0rc2` candidate is qualified with the selected consumer, artifacts, Runtime, and release evidence. See [TDE 1.0 Scope Lock](TDE_1_0_SCOPE_LOCK.md). | +| Next planned public release | `1.1.0`, only after public-wheel, adapter and cross-platform qualification evidence is complete and the reviewable release PR is approved. | | Security decision | G2-C is complete: the factual eight-repository inventory found no selected-pilot decision that justifies a TDE 1.0 security capability. Existing native controls remain the decision owners; see [Security Gap Assessment](SECURITY_GAP_ASSESSMENT.md). | -| Next recommended engineering work | Review and merge RC2 preparation in PR #131, then run the existing non-publishing candidate workflow from its exact merged `main` SHA with candidate version `1.0.0rc2`. The failed RC1 candidates remain immutable NO-GO evidence. Do not promote to warn, soft fail, required, or wider rollout. | +| Next recommended engineering work | Complete adapter/parser/source-classification tests, qualify the exact public wheel, then open one draft PR per selected consumer. Do not merge automatically or alter Observe mode. | ## Deferred Work diff --git a/INTEGRATION_MODEL.md b/INTEGRATION_MODEL.md index 8e26f31..e34d502 100644 --- a/INTEGRATION_MODEL.md +++ b/INTEGRATION_MODEL.md @@ -10,6 +10,15 @@ TDE is a standalone product. Consumers—including DJConnect—integrate only th Consumers must never integrate through runtime internals, private adapter APIs, repository layout, or unreleased behavior. Public reusable workflows, if introduced later, are contracts with their own versioning and authorization model; workflow access does not imply artifact or release write access. +## Complexity parity public contract + +`tde assess` and `tde qualify` discover a repository's primary product language +from canonical source classification and resolve registered complexity adapters +inside the published runtime. Consumers must only pin the runtime and invoke the +public CLI; they must not install or select Radon/Lizard adapters themselves. +The resulting `complexity.cyclomatic.product.maximum` follows the existing +policy and qualification path. TDE remains Observe-only in consumer workflows. + ## Runtime recovery public contract `tde assess --capability code_size ` is the public entrypoint for diff --git a/PRODUCT_BACKLOG.md b/PRODUCT_BACKLOG.md index 042723e..6b2fcfb 100644 --- a/PRODUCT_BACKLOG.md +++ b/PRODUCT_BACKLOG.md @@ -5,6 +5,7 @@ | Item | Concrete DJConnect value / intended consumer | Acceptance evidence | Explicit non-goals | | --- | --- | --- | --- | | Apple coverage improvement | Improve the separately reported Apple product coverage baseline from 38.37% through targeted UI and widget tests when the Apple team schedules the work. | A selected Apple test scope produces a reviewed canonical coverage artifact without changing TDE policy or thresholds. | TDE runtime/capability changes; coverage-threshold changes; treating the current 38.37% baseline as an integration failure. | +| TDE 1.1 complexity policy parity | Establish canonical primary-product-language complexity evidence for the seven selected DJConnect source consumers using one capability, policy and qualification route. | Public 1.1 runtime, adapter qualification, and seven reviewable Observe-only consumer PRs with provenance-rich primary-language complexity evidence. | Policy forks, threshold increases, consumer-local analyzers, automatic product refactoring, merge blocking, security/SBOM/dashboard work. | ## Completed in Generation 2 diff --git a/PRODUCT_ROADMAP.md b/PRODUCT_ROADMAP.md index c956444..5da2bc8 100644 --- a/PRODUCT_ROADMAP.md +++ b/PRODUCT_ROADMAP.md @@ -5,7 +5,8 @@ TDE `1.0.5` is the published and qualified public runtime. DJConnect is the primary product; TDE is the supporting engineering tool for reliable DJConnect pipeline decisions. Generation 1 and the bounded Generation 2 delivery are -complete; TDE is now maintenance-first. +complete. TDE 1.1 is the explicitly authorized, bounded cross-language +complexity parity increment. ## Generation 2 active roadmap @@ -20,6 +21,8 @@ complete; TDE is now maintenance-first. Consumer integration remains Observe-only. It does not create required checks, merge blocks, soft-fails, or consumer-side analyzer and policy duplication. +| TDE 1.1 Cross-language complexity parity | **In progress.** One canonical complexity capability gains primary-product-language evidence for Python, TypeScript/JavaScript, Swift, C/C++, and C#. | Qualified public runtime; one exact public pin in all seven Observe-only consumers; valid primary-language evidence and unchanged policy thresholds. | + ## Release and operating model Capabilities and consumer changes continue to merge in independently diff --git a/README.md b/README.md index 14d9988..cda40fe 100644 --- a/README.md +++ b/README.md @@ -4,19 +4,14 @@ DJConnect is the primary product. Technical Debt Engine (TDE) is its compact, supporting engineering tool for producing reliable pipeline assessment decisions through public evidence and CLI contracts. -Generation 2 is the consumer-driven **TDE 1.0 DJConnect Enablement Program**. -It does not expand TDE into a general platform: its active scope is coverage -completion, minimal dependency and security evidence, selected DJConnect CI -consumption, and one integrated `1.0.0` qualification and release. See the -[roadmap](PRODUCT_ROADMAP.md) and [active backlog](PRODUCT_BACKLOG.md). - -Release `0.2.0` is available as the PyPI distribution -[`technical-debt-engine-runtime`](https://pypi.org/project/technical-debt-engine-runtime/0.2.0/), -a [GitHub Release](https://github.com/pcvantol/technical-debt-engine/releases/tag/0.2.0), -and Docker image `docker.io/pcvantol/technical-debt-engine:0.2.0`. The Docker -OCI index is `sha256:8285a5082eaa1a5ac914b349ddec21c9e02cc4269421774d4f112383bc688ca9`; -no `latest` tag exists. See the [Runtime Qualification Report](RUNTIME_QUALIFICATION_REPORT_0.2.0.md) -for the immutable publication evidence and public-runtime validation. +TDE 1.0.5 completed the consumer-driven DJConnect enablement program. The +explicitly authorized TDE 1.1 increment adds cross-language primary-product +complexity parity without creating a new capability or changing Observe-only +governance. See the [roadmap](PRODUCT_ROADMAP.md) and [active +backlog](PRODUCT_BACKLOG.md). + +Release `1.0.5` is the current public baseline. TDE `1.1.0` is not a consumer +pin until its public-wheel and cross-platform qualification has completed. ## Product contracts @@ -24,6 +19,11 @@ Consumers integrate only through the public `tde` CLI, configuration, evidence schema, exit codes, and stable released contracts—not runtime internals. See [INTEGRATION_MODEL.md](INTEGRATION_MODEL.md). +The `complexity` capability discovers the dominant canonical product language: +Python uses Radon; JavaScript/TypeScript, Swift, C/C++ and C# use Lizard. All +five normalize to `complexity.cyclomatic.product.maximum` and the same policy. +See the [complexity support matrix](docs/complexity-support-matrix.md). + Operational repository assurance is available through `tde assure`; see [SOFTWARE_ASSURANCE.md](SOFTWARE_ASSURANCE.md) for its canonical evidence and candidate-artifact verification contract. ## Documentation @@ -34,6 +34,8 @@ Operational repository assurance is available through `tde assure`; see [SOFTWAR - [Capability model](CAPABILITY_MODEL.md) - [CLI specification](CLI_SPECIFICATION.md) - [Code Size Runtime contract](CODE_SIZE_RUNTIME.md) +- [Cross-language complexity support](docs/complexity-support-matrix.md) and + [ADR-0065](architecture/adr/ADR-0065-cross-language-complexity-policy-parity.md) - [Evidence schema](EVIDENCE_SCHEMA.md) - [Qualification model](QUALIFICATION_MODEL.md) - [Roadmap](PRODUCT_ROADMAP.md) and [backlog](PRODUCT_BACKLOG.md) diff --git a/architecture/adr/ADR-0065-cross-language-complexity-policy-parity.md b/architecture/adr/ADR-0065-cross-language-complexity-policy-parity.md new file mode 100644 index 0000000..e11f012 --- /dev/null +++ b/architecture/adr/ADR-0065-cross-language-complexity-policy-parity.md @@ -0,0 +1,89 @@ +# ADR-0065: Cross-language complexity policy parity + +## Status + +Accepted for the TDE 1.1 implementation increment. + +## Context + +TDE 1.0.5 publishes one canonical `complexity` capability, but its only +adapter is Radon and therefore only Python product source receives meaningful +complexity evidence. The seven selected DJConnect source consumers use Python, +TypeScript/JavaScript, Swift, C/C++, and C#. Auxiliary Python files must not +substitute for a repository's primary product language. + +The existing policy already evaluates the normalized product metric +`complexity.cyclomatic.product.maximum` with warning `15` and blocking `30`. +Those thresholds remain unchanged. + +## Decision + +TDE 1.1 retains the single `complexity` capability, policy route, assessment +profile and qualification route. It adds registered language adapters, not +language-specific capabilities or policies. + +| Product language | Adapter | Analyzer and pin | Platforms | Native output | +| --- | --- | --- | --- | --- | +| Python | `complexity.radon` | `radon==6.0.1` | Linux, macOS, Windows | JSON | +| TypeScript / JavaScript | `complexity.lizard` | `lizard==1.23.0` | Linux, macOS, Windows | CSV | +| Swift | `complexity.lizard` | `lizard==1.23.0` | macOS (also portable Python environments) | CSV | +| C / C++ | `complexity.lizard` | `lizard==1.23.0` | Linux, macOS, Windows | CSV | +| C# | `complexity.lizard` | `lizard==1.23.0` | Linux, macOS, Windows | CSV | + +Both analyzers are public Python packages and are exact runtime dependencies. +Radon is retained for Python because its structured Python analysis is the +existing public contract. Lizard is MIT licensed, publicly installable, +version-pinnable and documents function-level CCN, locations, deterministic +single-thread CLI operation, and support for all four added language families. + +The adapters normalize every emitted symbol to the existing canonical shape: +repository-relative path, language, symbol name and kind, location, CCN, +product-source classification, adapter and analyzer provenance. Raw analyzer +formats never reach policy evaluation. Each adapter result records executable, +package identity, analyzer version, language, host platform and a stable raw +output digest. + +`primaryLanguage` is discovered generically from canonical product-source +files: the recognised language with the highest nonblank source-line count. +Ties are retained as multiple primary languages. The product policy summary is +computed from symbols in those primary languages only. Non-primary symbols +remain symbol-, file-, language- and repository-level evidence, but cannot +satisfy a missing primary-language measurement. This prevents a Windows Python +helper from qualifying C# complexity, without repository-name logic. + +One shared source-classification model identifies product, test, fixture, +mock, generated, dependency/vendor, build/cache, verification and coverage +artifact paths. Generated coverage XML/JSON/HTML/intermediates are excluded +from both Code Size discovery and Complexity adapter input while remaining +available to the Coverage capability's configured artifact reader. + +Availability is explicit. No applicable product source is valid evidence with +no numeric product metric; unavailable analyzers, unsupported versions, +execution failures, malformed output, empty output for applicable source, +missing locations, partial language analysis, and duplicate symbols are +fail-closed structured limitations. They never become a zero metric or an +implicit PASS. + +## Rejected alternatives + +- **A new capability per language:** would fork policy, qualification and + evidence semantics, contrary to the public TDE contract. +- **Consumer-local analyzers or policies:** would make evidence nonportable and + let consumers bypass the exact public runtime. +- **SwiftLint plus language-specific tools:** SwiftLint's cyclomatic rule is a + lint violation mechanism rather than a complete, stable symbol metric feed; + separate toolchains also multiply release and platform qualifications. +- **A TDE parser/CCN algorithm:** forbidden without a separate architectural + decision; public analyzers already meet the required capability. +- **Let any supported-language result qualify a repository:** auxiliary scripts + could hide missing primary product coverage. + +## Consequences + +Raw CCN values remain analyzer-dependent and should not be interpreted as +mathematically interchangeable across languages. They are intentionally +evaluated through one transparent product policy after canonical normalization. +Existing Python/Radon evidence remains valid: TDE 1.1 adds optional provenance +and language summaries without changing the established metric keys or policy +thresholds. Consumers continue to use only the published `tde` CLI and remain +Observe-only. diff --git a/docs/assessment-profiles.md b/docs/assessment-profiles.md index cf4e5a1..ba82998 100644 --- a/docs/assessment-profiles.md +++ b/docs/assessment-profiles.md @@ -31,6 +31,13 @@ build caches. Local tooling and compilation output are not repository source code and cannot affect policy measurements. +Complexity uses the same canonical classification. It discovers the primary +product language by recognised product-source lines, measures it through a +registered public analyzer, and never lets auxiliary scripts in another +language substitute for it. Coverage XML/JSON/HTML/intermediate output is +excluded from Code Size and Complexity discovery but remains available to the +Coverage capability. + Profiles are JSON documents with an identifier, version, description, capability entries, a policy file and metadata. Each capability selects exactly one of `required` or `optional`. diff --git a/docs/assessment-schema.md b/docs/assessment-schema.md index 671885e..bb9ae0a 100644 --- a/docs/assessment-schema.md +++ b/docs/assessment-schema.md @@ -22,3 +22,9 @@ Compatibility is defined as follows: The Runtime validates every emitted capability, policy, decision, and assessment envelope before persistence. An incompatible or malformed schema is rejected fail-closed. + +TDE 1.1 preserves schema compatibility: cross-language complexity provenance is +additive adapter evidence. Existing Radon Python metrics and metric keys remain +valid; multi-language adapter identity, package/executable and platform fields +are carried in the existing `adapterResults` contract rather than a +language-specific top-level schema. diff --git a/docs/complexity-support-matrix.md b/docs/complexity-support-matrix.md new file mode 100644 index 0000000..1dd6360 --- /dev/null +++ b/docs/complexity-support-matrix.md @@ -0,0 +1,29 @@ +# Complexity support matrix + +TDE 1.1 exposes one public `complexity` capability. Adapter selection is +repository-discovery-driven; consumers never invoke an adapter themselves. + +| Language | Adapter | Analyzer | Pin | CI support | Evidence | +| --- | --- | --- | --- | --- | --- | +| Python | `complexity.radon` | Radon | `6.0.1` | Linux, macOS, Windows | canonical, qualified | +| JavaScript | `complexity.lizard` | Lizard | `1.23.0` | Linux, macOS, Windows | canonical, qualified | +| TypeScript | `complexity.lizard` | Lizard | `1.23.0` | Linux, macOS, Windows | canonical, qualified | +| Swift | `complexity.lizard` | Lizard | `1.23.0` | macOS | canonical, qualified | +| C / C++ | `complexity.lizard` | Lizard | `1.23.0` | Linux, macOS, Windows | canonical, qualified | +| C# | `complexity.lizard` | Lizard | `1.23.0` | Linux, macOS, Windows | canonical, qualified | + +Lizard is selected because its public CLI emits deterministic CSV with +function-level CCN and source locations, and supports every non-Python language +in the selected DJConnect set. TDE invokes it single-threaded against an +explicit, classified source list. Radon remains the Python adapter. + +The shared policy evaluates only primary product languages through +`complexity.cyclomatic.product.maximum` (warning `15`, blocking `30`). Test, +fixture, mock, generated, dependency, build, coverage and verification symbols +remain either excluded from discovery or separately classified; they cannot +inflate the primary product result. + +The adapter evidence includes analyzer executable, installed package identity, +version, adapter identity/version, language, platform, raw-output hash and +run-local timestamp through the enclosing assessment. Analyzer unavailability +and invalid evidence are structured fail-closed results, never zeroes. diff --git a/pyproject.toml b/pyproject.toml index f6165d1..da42834 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,10 +4,10 @@ build-backend = "setuptools.build_meta" [project] name = "technical-debt-engine-runtime" -version = "1.0.5" +version = "1.1.0" description = "Technical Debt Engine runtime foundation API" requires-python = ">=3.11" -dependencies = ["radon==6.0.1"] +dependencies = ["lizard==1.23.0", "radon==6.0.1"] [project.scripts] tde = "tde_cli.main:console_main" diff --git a/src/tde_cli/main.py b/src/tde_cli/main.py index 9eb3207..8447810 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.0.5" +CLI_VERSION = "1.1.0" GENERATION = "1" diff --git a/src/tde_runtime/analyzer_discovery.py b/src/tde_runtime/analyzer_discovery.py index ecb7837..e6db0a4 100644 --- a/src/tde_runtime/analyzer_discovery.py +++ b/src/tde_runtime/analyzer_discovery.py @@ -4,11 +4,23 @@ import re import shutil import subprocess +import sys +from pathlib import Path from typing import Any def discover(executable_name: str, minimum_version: tuple[int, int], timeout: int) -> dict[str, Any]: executable = shutil.which(executable_name) + # A public wheel installs analyzer console scripts into the active Python + # environment. Invoking `tde` by absolute path does not necessarily prepend + # that environment's bin/Scripts directory to PATH, so discover the sibling + # script deterministically before declaring a bundled dependency missing. + if not executable: + # `sys.executable` can be a symlink to a base interpreter in a virtual + # environment; `sys.prefix` remains the active environment identity. + directories = (Path(sys.prefix) / ("Scripts" if sys.platform == "win32" else "bin"), Path(sys.executable).parent) + candidates = tuple(candidate for directory in directories for candidate in (directory / executable_name, directory / f"{executable_name}.exe")) + executable = next((str(candidate) for candidate in candidates if candidate.is_file()), None) if not executable: return {"status": "ANALYZER_NOT_FOUND", "limitation": {"id": f"analyzer.{executable_name}.unavailable", "description": f"{executable_name} is not on PATH.", "cause": "analyzer unavailable"}} try: diff --git a/src/tde_runtime/code_size.py b/src/tde_runtime/code_size.py index 440c3f4..692f012 100644 --- a/src/tde_runtime/code_size.py +++ b/src/tde_runtime/code_size.py @@ -1,11 +1,12 @@ """Code Size capability adapter backed by an explicitly installed cloc executable.""" from __future__ import annotations -import json, subprocess +import json, subprocess, tempfile from collections import defaultdict from hashlib import sha256 from pathlib import Path from typing import Any from .analyzer_discovery import discover +from .source_classification import EXCLUDED_DIRECTORIES, classification CAPABILITY_ID = "code_size" CAPABILITY_VERSION = "0.1.0" @@ -15,32 +16,47 @@ # These paths contain installed dependencies or generated build output. They # are excluded before cloc runs so policy metrics describe repository source, # rather than a local checkout's tooling state. -EXCLUDED_DIRECTORIES = ( - ".git", ".tde", "__pycache__", ".venv", "venv", - "build", "dist", "bin", "obj", ".build", ".swiftpm", ".pio", - ".release", ".release-venv", ".public-release", - "node_modules", "vendor", "third_party", "generated", "artifacts", -) +EXCLUDED_DIRECTORIES = tuple(sorted(EXCLUDED_DIRECTORIES)) def classify(path: str) -> str: value = path.replace("\\", "/").lower() - if value.startswith(("vendor/", "third_party/", "node_modules/")) or any(part in value for part in ("/vendor/", "/third_party/", "/node_modules/")): return "VENDOR" - if value.startswith(("generated/", "build/", "dist/")) or any(part in value for part in ("/generated/", "/build/", "/dist/")): return "GENERATED" - if value.startswith(("tests/", "test/", "spec/")) or "/tests/" in value: return "TEST" + source_class = classification(value) + if source_class == "DEPENDENCY": return "VENDOR" + if source_class in {"GENERATED", "COVERAGE_ARTIFACT"}: return "GENERATED" + if source_class in {"TEST", "FIXTURE", "VERIFICATION", "SAMPLE"}: return "TEST" if value.startswith(("docs/", "documentation/")) or value.endswith((".md", ".rst", ".txt")): return "DOCUMENTATION" if value.endswith((".yml", ".yaml", ".json", ".toml", ".ini")): return "CONFIGURATION" return "SOURCE" + +def _coverage_exclude_list(root: Path) -> str | None: + paths = [str(path) for path in root.rglob("*") if path.is_file() and classification(path.relative_to(root)) == "COVERAGE_ARTIFACT"] + if not paths: + return None + handle = tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", suffix=".txt", delete=False) + with handle: + handle.write("\n".join(sorted(paths))) + handle.write("\n") + return handle.name + def analyze(root: Path, timeout: int = 60) -> dict[str, Any]: discovery = discover("cloc", MINIMUM_ANALYZER_VERSION, timeout) if discovery["status"] != "VALID": return {"status": discovery["status"], "limitations": [discovery["limitation"]]} + exclude_list = _coverage_exclude_list(root) + command = [discovery["executable"], "--json", "--by-file", "--quiet", f"--exclude-dir={','.join(EXCLUDED_DIRECTORIES)}"] + if exclude_list: + command.append(f"--exclude-list-file={exclude_list}") + command.append(str(root)) try: - result = subprocess.run([discovery["executable"], "--json", "--by-file", "--quiet", f"--exclude-dir={','.join(EXCLUDED_DIRECTORIES)}", str(root)], capture_output=True, text=True, timeout=timeout, check=True) + result = subprocess.run(command, capture_output=True, text=True, timeout=timeout, check=True) raw = result.stdout data = json.loads(raw) except (subprocess.TimeoutExpired, subprocess.CalledProcessError, json.JSONDecodeError) as error: return {"status":"FAILED_CLOSED", "limitations":[{"id":"analyzer.cloc.failed","description":str(error),"cause":"analyzer execution failed"}]} + finally: + if exclude_list: + Path(exclude_list).unlink(missing_ok=True) files, languages = [], defaultdict(lambda: {"files":0,"code":0,"comment":0,"blank":0}) totals = defaultdict(int) entries = ((name, item) for name, item in data.items() if name not in {"header", "SUM"}) diff --git a/src/tde_runtime/complexity.py b/src/tde_runtime/complexity.py index 7493f44..1de6099 100644 --- a/src/tde_runtime/complexity.py +++ b/src/tde_runtime/complexity.py @@ -1,20 +1,31 @@ -"""Python cyclomatic-complexity adapter backed by the installed Radon CLI.""" +"""Canonical, multi-language cyclomatic-complexity adapter orchestration.""" from __future__ import annotations +import csv import fnmatch +import io import json +import platform import subprocess +import tempfile from hashlib import sha256 from pathlib import Path from typing import Any, Mapping + from .analyzer_discovery import discover +from .source_classification import classification, language_for, primary_languages CAPABILITY_ID = "complexity" -CAPABILITY_VERSION = "0.1.0" -ADAPTER_ID = "complexity.radon" -ADAPTER_VERSION = "0.1.0" -MINIMUM_ANALYZER_VERSION = (6, 0) +CAPABILITY_VERSION = "1.1.0" +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" +MINIMUM_RADON_VERSION = (6, 0) +MINIMUM_LIZARD_VERSION = (1, 23) +LIZARD_LANGUAGES = {"JavaScript": "javascript", "TypeScript": "typescript", "Swift": "swift", "C": "cpp", "C++": "cpp", "C#": "csharp"} + def _items(value: object) -> tuple[str, ...]: if isinstance(value, str): @@ -23,6 +34,7 @@ def _items(value: object) -> tuple[str, ...]: return tuple(value) return () + def _relative(root: Path, name: str) -> str: path = Path(name) if not path.is_absolute(): @@ -30,38 +42,18 @@ def _relative(root: Path, name: str) -> str: try: return path.resolve().relative_to(root.resolve()).as_posix() except ValueError: - # Radon is expected to report files below the selected root. Never - # preserve an absolute host path in evidence if an analyzer violates - # that expectation. return path.name def classify_path(path: str) -> str: - """Classify a complexity symbol without suppressing it from evidence.""" - value = path.replace("\\", "/").lower() - parts = tuple(part for part in value.split("/") if part) - filename = parts[-1] if parts else value - if (any(part in {"fixture", "fixtures", "__fixtures__", "testdata", "test-data"} for part in parts) - or value.startswith(("fixture/", "fixtures/", "testdata/", "test-data/"))): - return "FIXTURE" - if (any(part in {"verification", "verify"} for part in parts) - or filename.startswith(("validate_", "verify_"))): - return "VERIFICATION" - if (any(part in {"test", "tests", "spec", "specs"} for part in parts) - or filename.startswith(("test_", "spec_")) or filename.endswith(("_test.py", "_spec.py"))): - return "TEST" - return "PRODUCT_SOURCE" + """Compatibility entrypoint for shared canonical classification.""" + return classification(path) -def _portable_native_output(root: Path, data: Mapping[str, Any]) -> tuple[str, dict[str, Any]]: - """Return a stable Radon projection without runner-specific paths.""" - normalized = {_relative(root, path): symbols for path, symbols in data.items()} - ordered = {path: normalized[path] for path in sorted(normalized)} - return json.dumps(ordered, sort_keys=True, separators=(",", ":")), ordered - def _thresholds(configuration: Mapping[str, Any]) -> dict[str, int]: supplied = configuration.get("thresholds", {}) - if not isinstance(supplied, Mapping): supplied = {} + if not isinstance(supplied, Mapping): + supplied = {} values = {"high": 11, "veryHigh": 21, "critical": 41} for key in values: if key in supplied: @@ -72,33 +64,142 @@ def _thresholds(configuration: Mapping[str, Any]) -> dict[str, int]: raise ValueError("complexity thresholds must satisfy high < veryHigh < critical") return values -def analyze(root: Path, timeout: int = 60, configuration: Mapping[str, Any] | None = None) -> dict[str, Any]: - """Execute Radon deterministically and retain native output for evidence.""" - configuration = configuration or {} - discovery = discover("radon", MINIMUM_ANALYZER_VERSION, timeout) + +def _included_paths(root: Path, languages: tuple[str, ...], configuration: Mapping[str, Any]) -> list[Path]: + ignored = _items(configuration.get("ignoredPaths")) + selected: list[Path] = [] + for path in sorted(root.rglob("*")): + if not path.is_file(): + continue + relative = path.relative_to(root).as_posix() + language = language_for(relative) + if language not in languages: + continue + if classification(relative) in {"DEPENDENCY", "GENERATED", "COVERAGE_ARTIFACT", "VERIFICATION", "SAMPLE"}: + continue + if any(fnmatch.fnmatch(relative, pattern) or relative.startswith(pattern.rstrip("/") + "/") for pattern in ignored): + continue + selected.append(path) + return selected + + +def _portable_native_output(root: Path, data: Mapping[str, Any]) -> tuple[str, dict[str, Any]]: + normalized = {_relative(root, path): symbols for path, symbols in data.items()} + ordered = {path: normalized[path] for path in sorted(normalized)} + return json.dumps(ordered, sort_keys=True, separators=(",", ":")), ordered + + +def _adapter(adapter_id: str, analyzer_id: str, discovery: Mapping[str, Any], language: str, raw: str) -> dict[str, Any]: + return { + "id": adapter_id, "version": ADAPTER_VERSION, + "analyzer": {"id": analyzer_id, "version": discovery["version"], "executable": discovery["executable"], + "package": f"{analyzer_id}=={discovery['version']}", "platform": platform.system().lower()}, + "language": language, "rawOutput": raw, "rawOutputHash": "sha256:" + sha256(raw.encode()).hexdigest(), + } + + +def _radon(root: Path, paths: list[Path], timeout: int) -> dict[str, Any]: + discovery = discover("radon", MINIMUM_RADON_VERSION, timeout) if discovery["status"] != "VALID": return {"status": discovery["status"], "limitations": [discovery["limitation"]]} try: - completed = subprocess.run([discovery["executable"], "cc", "--json", str(root)], capture_output=True, text=True, timeout=timeout, check=True) + completed = subprocess.run([discovery["executable"], "cc", "--json", *map(str, paths)], capture_output=True, text=True, timeout=timeout, check=True) raw, data = _portable_native_output(root, json.loads(completed.stdout)) except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.CalledProcessError, json.JSONDecodeError) as error: - return {"status":"BLOCKED", "limitations":[{"id":"analyzer.radon.failed","description":str(error),"cause":"analyzer execution failed"}]} - try: - thresholds = _thresholds(configuration) - except ValueError as error: - return {"status":"BLOCKED", "limitations":[{"id":"complexity.configuration.invalid","description":str(error),"cause":"invalid configuration"}]} - defaults = (".git/**", ".tde/**", "build/**", "dist/**", "*.egg-info/**", ".venv/**", "venv/**", "__pycache__/**") - ignored_paths, ignored_symbols = defaults + _items(configuration.get("ignoredPaths")), set(_items(configuration.get("ignoredSymbols"))) - symbols, skipped = [], 0 + return {"status": "EXECUTION_FAILED", "limitations": [{"id": "analyzer.radon.failed", "description": str(error), "cause": "analyzer execution failed"}]} + symbols = [] for native_path, native_symbols in sorted(data.items()): path = _relative(root, native_path) - if any(fnmatch.fnmatch(path, pattern) or path.startswith(pattern.rstrip("/")+"/") for pattern in ignored_paths): - skipped += len(native_symbols); continue for native in sorted(native_symbols, key=lambda item: (item.get("lineno", 0), item.get("name", ""))): - if native.get("name") in ignored_symbols: - skipped += 1; continue - symbols.append({"path":path,"classification":classify_path(path),"language":"Python","name":native["name"],"type":native.get("type","symbol"),"line":native.get("lineno"),"endLine":native.get("endline"),"complexity":native["complexity"]}) - limitations=[] - if skipped: limitations.append({"id":"complexity.configuration.ignored","description":f"{skipped} symbol(s) were excluded by Complexity configuration.","cause":"configured exclusion"}) - if not symbols: limitations.append({"id":"complexity.python.no_symbols","description":"Radon found no supported Python symbols after exclusions.","cause":"analyzer capability limitation"}) - return {"status":"VALID","adapter":{"id":ADAPTER_ID,"version":ADAPTER_VERSION},"analyzer":{"id":"radon","version":discovery["version"]},"rawOutput":raw,"rawOutputHash":"sha256:"+sha256(raw.encode()).hexdigest(),"symbols":symbols,"thresholds":thresholds,"limitations":limitations} + if not native.get("name") or not isinstance(native.get("complexity"), int) or not native.get("lineno"): + return {"status": "INVALID_EVIDENCE", "limitations": [{"id": "complexity.radon.malformed_output", "description": "Radon omitted required symbol fields.", "cause": "invalid analyzer evidence"}]} + symbols.append({"path": path, "classification": classification(path), "language": "Python", "name": native["name"], + "type": native.get("type", "symbol"), "line": native["lineno"], "endLine": native.get("endline"), + "complexity": native["complexity"], "adapterId": RADON_ADAPTER_ID, "toolId": "radon"}) + return {"status": "VALID", "symbols": symbols, "adapter": _adapter(RADON_ADAPTER_ID, "radon", discovery, "Python", raw)} + + +def _lizard(root: Path, paths: list[Path], languages: tuple[str, ...], timeout: int) -> dict[str, Any]: + discovery = discover("lizard", MINIMUM_LIZARD_VERSION, timeout) + if discovery["status"] != "VALID": + return {"status": discovery["status"], "limitations": [discovery["limitation"]]} + input_file: str | None = None + try: + handle = tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", suffix=".txt", delete=False) + with handle: + handle.write("\n".join(str(path) for path in paths)) + handle.write("\n") + input_file = handle.name + command = [discovery["executable"], "--csv", "-V", "-i", "-1", "-f", input_file] + for language in sorted({LIZARD_LANGUAGES[language] for language in languages}): + command.extend(["-l", language]) + completed = subprocess.run(command, capture_output=True, text=True, timeout=timeout, check=True) + raw = completed.stdout + rows = list(csv.reader(io.StringIO(raw))) + except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.CalledProcessError, csv.Error, OSError) as error: + return {"status": "EXECUTION_FAILED", "limitations": [{"id": "analyzer.lizard.failed", "description": str(error), "cause": "analyzer execution failed"}]} + finally: + if input_file: + Path(input_file).unlink(missing_ok=True) + symbols = [] + for row in rows: + if not row: + continue + if row[0] == "NLOC": + continue + if len(row) != 11: + return {"status": "INVALID_EVIDENCE", "limitations": [{"id": "complexity.lizard.malformed_output", "description": "Lizard CSV did not contain eleven symbol fields.", "cause": "invalid analyzer evidence"}]} + try: + complexity, name, path, line, end_line = int(row[1]), row[7], _relative(root, row[6]), int(row[9]), int(row[10]) + 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) + 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, + "type": "function", "line": line, "endLine": end_line or None, "complexity": complexity, + "adapterId": LIZARD_ADAPTER_ID, "toolId": "lizard"}) + return {"status": "VALID", "symbols": symbols, "adapter": _adapter(LIZARD_ADAPTER_ID, "lizard", discovery, ", ".join(languages), raw)} + + +def analyze(root: Path, timeout: int = 60, configuration: Mapping[str, Any] | None = None) -> dict[str, Any]: + """Run every registered adapter applicable to discovered primary languages.""" + configuration = configuration or {} + try: + thresholds = _thresholds(configuration) + except ValueError as error: + return {"status": "BLOCKED", "limitations": [{"id": "complexity.configuration.invalid", "description": str(error), "cause": "invalid configuration"}]} + primary = primary_languages(root) + if not primary: + return {"status": "NO_APPLICABLE_PRODUCT_SOURCE", "symbols": [], "adapters": [], "thresholds": thresholds, + "primaryLanguages": [], "limitations": [{"id": "complexity.product_source.none", "description": "No supported product source files were discovered.", "cause": "no applicable product source"}]} + symbols: list[dict[str, Any]] = [] + adapters: list[dict[str, Any]] = [] + limitations: list[dict[str, Any]] = [] + for language_group, runner in ((("Python",), _radon), (tuple(language for language in primary if language in LIZARD_LANGUAGES), _lizard)): + languages = tuple(language for language in language_group if language in primary) + if not languages: + continue + paths = _included_paths(root, languages, configuration) + product_paths = [path for path in paths if classification(path.relative_to(root)) == "PRODUCT_SOURCE"] + if not product_paths: + return {"status": "NO_APPLICABLE_PRODUCT_SOURCE", "symbols": symbols, "adapters": adapters, "thresholds": thresholds, + "primaryLanguages": list(primary), "limitations": [{"id": "complexity.product_source.none", "description": f"No analysable product files were discovered for {', '.join(languages)}.", "cause": "no applicable product source"}]} + result = runner(root, paths, timeout) if runner is _radon else runner(root, paths, languages, timeout) + if result["status"] != "VALID": + return {"status": result["status"], "symbols": symbols, "adapters": adapters, "thresholds": thresholds, + "primaryLanguages": list(primary), "limitations": result["limitations"]} + if not result["symbols"]: + return {"status": "INVALID_EVIDENCE", "symbols": symbols, "adapters": adapters, "thresholds": thresholds, + "primaryLanguages": list(primary), "limitations": [{"id": "complexity.analyzer.empty_output", "description": f"{result['adapter']['analyzer']['id']} emitted no symbols for applicable product source.", "cause": "invalid analyzer evidence"}]} + symbols.extend(result["symbols"]) + 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"}]} + 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/execution.py b/src/tde_runtime/execution.py index 40a3a0f..cf3f153 100644 --- a/src/tde_runtime/execution.py +++ b/src/tde_runtime/execution.py @@ -7,11 +7,12 @@ from hashlib import sha256 from .code_size import ADAPTER_ID, CAPABILITY_ID, CAPABILITY_VERSION, analyze -from .complexity import ADAPTER_ID as COMPLEXITY_ADAPTER_ID, CAPABILITY_ID as COMPLEXITY_CAPABILITY_ID, CAPABILITY_VERSION as COMPLEXITY_CAPABILITY_VERSION, analyze as analyze_complexity +from .complexity import ADAPTER_ID as COMPLEXITY_ADAPTER_ID, LIZARD_ADAPTER_ID as COMPLEXITY_LIZARD_ADAPTER_ID, CAPABILITY_ID as COMPLEXITY_CAPABILITY_ID, CAPABILITY_VERSION as COMPLEXITY_CAPABILITY_VERSION, analyze as analyze_complexity from .coverage import ADAPTER_ID as COVERAGE_ADAPTER_ID, CAPABILITY_ID as COVERAGE_CAPABILITY_ID, CAPABILITY_VERSION as COVERAGE_CAPABILITY_VERSION, analyze as analyze_coverage from .dependency_health import ADAPTER_ID as DEPENDENCY_ADAPTER_ID, CAPABILITY_ID as DEPENDENCY_CAPABILITY_ID, CAPABILITY_VERSION as DEPENDENCY_CAPABILITY_VERSION, analyze as analyze_dependencies from .maintainability import CAPABILITY_ID as MAINTAINABILITY_CAPABILITY_ID, CAPABILITY_VERSION as MAINTAINABILITY_CAPABILITY_VERSION, derive as derive_maintainability from .registries import AdapterRegistry, CapabilityRegistry +from .source_classification import primary_languages class CapabilityExecutionEngine: @@ -34,14 +35,24 @@ def plan(self, context: Any) -> dict[str, Any]: # not encode any capability-specific execution sequence. planned = [item["id"] for item in registered if item["id"] in requested_plan] unsupported = [identifier for identifier in enabled if identifier not in available] - selected = {identifier: self._adapter_registry.select(available[identifier]) for identifier in planned if "supportedAnalyzers" in available[identifier]} - planned_adapters = [binding["id"] for binding in selected.values() if binding] + selected: dict[str, Any] = {} + for identifier in planned: + if "supportedAnalyzers" not in available[identifier]: + continue + if identifier == COMPLEXITY_CAPABILITY_ID: + supported = set(available[identifier]["supportedAnalyzers"]) + languages = set(primary_languages(context.repository_root)) + selected[identifier] = [item for item in self._adapter_registry.discover() + if item["id"] in supported and languages.intersection(item.get("languages", ()))] + else: + selected[identifier] = self._adapter_registry.select(available[identifier]) + planned_adapters = [binding["id"] for value in selected.values() for binding in (value if isinstance(value, list) else [value]) if binding] return { "state": "PLANNED", "capabilities": planned, "unsupportedCapabilities": unsupported, "plannedAdapters": planned_adapters, - "analyzerBindings": {identifier: binding["id"] if binding else None for identifier, binding in selected.items()}, + "analyzerBindings": {identifier: [item["id"] for item in binding] if isinstance(binding, list) else (binding["id"] if binding else None) for identifier, binding in selected.items()}, "parallelReady": True, "retries": "NONE", } @@ -70,7 +81,8 @@ def execute(self, context: Any) -> dict[str, Any]: else: evidence["blockedCapabilities"].append(identifier) evidence["limitations"].extend(result.get("limitations", [])) - evidence["workItems"].append({"capabilityId": identifier, "adapterId": adapter_ids[0] if adapter_ids else None, + configured_adapter = plan.get("analyzerBindings", {}).get(identifier) + evidence["workItems"].append({"capabilityId": identifier, "adapterId": configured_adapter if isinstance(configured_adapter, str) else (adapter_ids[0] if adapter_ids else None), "state": state, "durationMs": result["executionTiming"]["durationMs"]}) for identifier in plan["unsupportedCapabilities"]: @@ -96,7 +108,7 @@ def execute(self, context: Any) -> dict[str, Any]: "executionEvidence": evidence, } - def _dispatch(self, identifier: str, selected_adapter: str | None, context: Any, measurements: list[dict[str, Any]]) -> dict[str, Any]: + def _dispatch(self, identifier: str, selected_adapter: Any, context: Any, measurements: list[dict[str, Any]]) -> dict[str, Any]: started = perf_counter() # Repository-wide cloc scans can legitimately exceed one minute on a # public consumer checkout. A bounded five-minute default remains @@ -106,12 +118,12 @@ def _dispatch(self, identifier: str, selected_adapter: str | None, context: Any, result = analyze(context.repository_root, timeout) duration = int((perf_counter() - started) * 1000) return self._code_size_result(context, result, duration) if result["status"] == "VALID" else self._blocked(CAPABILITY_ID, CAPABILITY_VERSION, [ADAPTER_ID], result["limitations"], duration, result["status"]) - if identifier == COMPLEXITY_CAPABILITY_ID and selected_adapter == COMPLEXITY_ADAPTER_ID: + if identifier == COMPLEXITY_CAPABILITY_ID and set(selected_adapter if isinstance(selected_adapter, list) else [selected_adapter]).issubset({COMPLEXITY_ADAPTER_ID, COMPLEXITY_LIZARD_ADAPTER_ID}): settings = context.execution_options.get("capabilities", {}).get(COMPLEXITY_CAPABILITY_ID, {}) result = analyze_complexity(context.repository_root, timeout, settings) duration = int((perf_counter() - started) * 1000) - if result["status"] != "VALID": - return self._blocked(COMPLEXITY_CAPABILITY_ID, COMPLEXITY_CAPABILITY_VERSION, [COMPLEXITY_ADAPTER_ID], result["limitations"], duration) + if result["status"] not in {"VALID", "NO_APPLICABLE_PRODUCT_SOURCE"}: + return self._blocked(COMPLEXITY_CAPABILITY_ID, COMPLEXITY_CAPABILITY_VERSION, [COMPLEXITY_ADAPTER_ID, COMPLEXITY_LIZARD_ADAPTER_ID], result["limitations"], duration, result["status"]) return self._complexity_result(context, result, duration) if identifier == COVERAGE_CAPABILITY_ID and selected_adapter == COVERAGE_ADAPTER_ID: settings = context.execution_options.get("capabilities", {}).get(COVERAGE_CAPABILITY_ID, {}) @@ -211,23 +223,28 @@ def _code_size_result(context: Any, result: dict[str, Any], duration: int) -> di @staticmethod def _complexity_result(context: Any, result: dict[str, Any], duration: int) -> dict[str, Any]: symbols, measurements, findings = result["symbols"], [], [] + primary_languages = set(result.get("primaryLanguages", [])) def measurement_id(scope: str, entity: str, metric: str) -> str: return f"complexity.{scope}.{sha256(entity.encode()).hexdigest()[:16]}.{metric}" - def add_summary(scope: str, entity: str, values: list[int], metric_prefix: str = "complexity.cyclomatic.") -> None: + def add_summary(scope: str, entity: str, values: list[int], metric_prefix: str = "complexity.cyclomatic.", adapter_id: str = "complexity.normalized", tool_id: str = "normalized") -> None: if not values: return for metric, value, aggregation in (("average", sum(values)/len(values), "mean"), ("maximum", max(values), "maximum")): - measurements.append({"measurementId":measurement_id(scope,entity,metric),"capabilityId":COMPLEXITY_CAPABILITY_ID,"metricKey":f"{metric_prefix}{metric}","value":value,"unit":"score","scope":scope,"targetEntityId":entity,"aggregation":aggregation,"sourceAdapterId":COMPLEXITY_ADAPTER_ID,"sourceToolId":"radon"}) + measurements.append({"measurementId":measurement_id(scope,entity,metric),"capabilityId":COMPLEXITY_CAPABILITY_ID,"metricKey":f"{metric_prefix}{metric}","value":value,"unit":"score","scope":scope,"targetEntityId":entity,"aggregation":aggregation,"sourceAdapterId":adapter_id,"sourceToolId":tool_id}) for lower, upper, band in ((1,10,"low"),(11,20,"high"),(21,40,"very_high"),(41,None,"critical")): - measurements.append({"measurementId":measurement_id(scope,entity,f"distribution.{band}"),"capabilityId":COMPLEXITY_CAPABILITY_ID,"metricKey":"complexity.cyclomatic.distribution","value":sum(1 for value in values if value >= lower and (upper is None or value <= upper)),"unit":"symbols","scope":scope,"targetEntityId":f"{entity}.distribution.{band}","aggregation":"count","sourceAdapterId":COMPLEXITY_ADAPTER_ID,"sourceToolId":"radon"}) + measurements.append({"measurementId":measurement_id(scope,entity,f"distribution.{band}"),"capabilityId":COMPLEXITY_CAPABILITY_ID,"metricKey":"complexity.cyclomatic.distribution","value":sum(1 for value in values if value >= lower and (upper is None or value <= upper)),"unit":"symbols","scope":scope,"targetEntityId":f"{entity}.distribution.{band}","aggregation":"count","sourceAdapterId":adapter_id,"sourceToolId":tool_id}) add_summary("repository", context.repository_id, [symbol["complexity"] for symbol in symbols]) - product_symbols = [symbol for symbol in symbols if symbol["classification"] == "PRODUCT_SOURCE"] + product_symbols = [symbol for symbol in symbols if symbol["classification"] == "PRODUCT_SOURCE" and symbol["language"] in primary_languages] add_summary("repository_product", context.repository_id, [symbol["complexity"] for symbol in product_symbols], "complexity.cyclomatic.product.") + measurements.append({"measurementId": "complexity.repository_product.symbol_count", "capabilityId": COMPLEXITY_CAPABILITY_ID, + "metricKey": "complexity.cyclomatic.product.symbol_count", "value": len(product_symbols), "unit": "symbols", + "scope": "repository_product", "targetEntityId": context.repository_id, "aggregation": "count", + "sourceAdapterId": "complexity.normalized", "sourceToolId": "normalized"}) by_language, by_file = {}, {} for symbol in symbols: by_language.setdefault(symbol["language"], []).append(symbol); by_file.setdefault(symbol["path"], []).append(symbol) entity = "symbol." + sha256(f"{symbol['path']}:{symbol['name']}:{symbol['line']}".encode()).hexdigest()[:16] evidence = measurement_id("symbol", entity, "value") - measurements.append({"measurementId":evidence,"capabilityId":COMPLEXITY_CAPABILITY_ID,"metricKey":"complexity.cyclomatic.value","value":symbol["complexity"],"unit":"score","scope":"symbol","targetEntityId":entity,"aggregation":"value","sourceAdapterId":COMPLEXITY_ADAPTER_ID,"sourceToolId":"radon"}) + measurements.append({"measurementId":evidence,"capabilityId":COMPLEXITY_CAPABILITY_ID,"metricKey":"complexity.cyclomatic.value","value":symbol["complexity"],"unit":"score","scope":"symbol","targetEntityId":entity,"aggregation":"value","sourceAdapterId":symbol["adapterId"],"sourceToolId":symbol["toolId"]}) thresholds = result["thresholds"] if symbol["complexity"] >= thresholds["critical"]: rule, severity, title, threshold = "complexity.critical", "CRITICAL", "Critical Complexity", thresholds["critical"] elif symbol["complexity"] >= thresholds["veryHigh"]: rule, severity, title, threshold = "complexity.very_high", "HIGH", "Very High Complexity", thresholds["veryHigh"] @@ -238,9 +255,13 @@ def add_summary(scope: str, entity: str, values: list[int], metric_prefix: str = for path, values in sorted(by_file.items()): add_summary("file", "file."+sha256(path.encode()).hexdigest()[:16], [item["complexity"] for item in values]) if not symbols: findings.append({"findingId":"complexity.missing.repository","capabilityId":COMPLEXITY_CAPABILITY_ID,"ruleId":"complexity.missing","severity":"INFO","category":"COMPLEXITY","title":"Missing Complexity","description":"No supported symbols were measured.","affectedEntityId":context.repository_id,"evidenceReferences":[],"state":"OPEN","regression":"UNKNOWN","confidence":1,"suppressible":False}) - adapter = {"adapter":result["adapter"],"analyzer":result["analyzer"],"execution":"SUCCESS","rawOutputHash":result["rawOutputHash"],"rawOutput":result["rawOutput"],"measuredScope":["repository","repository_product","language","file","symbol"],"completeness":1,"draftMeasurements":measurements,"draftFindings":findings,"warnings":[],"errors":[],"limitations":result["limitations"],"executionTiming":{"durationMs":duration}} - capability = {"capabilityId":COMPLEXITY_CAPABILITY_ID,"capabilityVersion":COMPLEXITY_CAPABILITY_VERSION,"status":"VALID","adapterIds":[COMPLEXITY_ADAPTER_ID],"completeness":1,"qualificationApplicable":True,"limitations":result["limitations"],"executionTiming":{"durationMs":duration}} - return {"measurements":measurements,"findings":findings,"adapterResults":[adapter],"capabilityResults":[capability]} + adapters = result.get("adapters", []) + adapter_results = [{"adapter": {"id": adapter["id"], "version": adapter["version"]}, "analyzer": adapter["analyzer"], "execution": "SUCCESS", + "rawOutputHash": adapter["rawOutputHash"], "rawOutput": adapter["rawOutput"], "measuredScope": ["repository", "repository_product", "language", "file", "symbol"], + "completeness": 1, "draftMeasurements": measurements, "draftFindings": findings, "warnings": [], "errors": [], "limitations": result["limitations"], "executionTiming": {"durationMs": duration}} + for adapter in adapters] + capability = {"capabilityId":COMPLEXITY_CAPABILITY_ID,"capabilityVersion":COMPLEXITY_CAPABILITY_VERSION,"status":"VALID","adapterIds":[adapter["id"] for adapter in adapters],"completeness":1,"qualificationApplicable":True,"limitations":result["limitations"],"executionTiming":{"durationMs":duration}} + return {"measurements":measurements,"findings":findings,"adapterResults":adapter_results,"capabilityResults":[capability]} @staticmethod def _coverage_result(context: Any, result: dict[str, Any], duration: int) -> dict[str, Any]: diff --git a/src/tde_runtime/policies/generation-1.json b/src/tde_runtime/policies/generation-1.json index ef9c2d1..1376e50 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"], + "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"], "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 3346f09..aad664d 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": "0.1.0", "description": "Canonical cyclomatic-complexity metrics", "outputContract": "tde.complexity.v1", "analyzerSelection": "highest_priority", "qualificationRules": "complete_adapter_evidence", "supportedAnalyzers": ("complexity.radon",)}, + {"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": "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,7 +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":"0.1.0","analyzer":"radon", "capabilities": ("complexity",), "minimumVersion": "6.0", "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":"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 2ec08f0..c4e4ac1 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.0.5" +RUNTIME_VERSION = "1.1.0" EVIDENCE_SCHEMA_VERSION = "1.0.0" diff --git a/src/tde_runtime/source_classification.py b/src/tde_runtime/source_classification.py new file mode 100644 index 0000000..01b1b4a --- /dev/null +++ b/src/tde_runtime/source_classification.py @@ -0,0 +1,103 @@ +"""Canonical repository-source classification shared by analyzers. + +The rules are intentionally path and filename based: they are portable, +deterministic and do not depend on consumer repository names. They separate +real product source from material that must remain inspectable elsewhere (for +example a configured coverage artifact) without contributing to product +metrics. +""" + +from __future__ import annotations + +from pathlib import Path + + +EXCLUDED_DIRECTORIES = frozenset({ + ".git", ".tde", ".venv", "venv", "env", ".tox", ".nox", "__pycache__", + "node_modules", "vendor", "vendors", "third_party", "third-party", + "packages", "build", "dist", "bin", "obj", ".build", ".swiftpm", ".pio", + ".xcode-derived", ".deriveddata", "deriveddata", "coverage", "coverage-html", + "htmlcov", "artifacts", "reports", "report", "test-results", "test-results", + "cache", ".cache", ".release", ".release-venv", ".public-release", "generated", +}) + +LANGUAGE_BY_EXTENSION = { + ".py": "Python", + ".js": "JavaScript", ".mjs": "JavaScript", ".cjs": "JavaScript", ".jsx": "JavaScript", + ".ts": "TypeScript", ".tsx": "TypeScript", + ".swift": "Swift", + ".c": "C", ".h": "C", ".cc": "C++", ".cpp": "C++", ".cxx": "C++", + ".hpp": "C++", ".hh": "C++", ".hxx": "C++", + ".cs": "C#", +} + + +def normalise(path: str | Path) -> str: + return str(path).replace("\\", "/").strip("/") + + +def _parts(path: str | Path) -> tuple[str, ...]: + return tuple(part.lower() for part in normalise(path).split("/") if part) + + +def excluded_directory(path: str | Path) -> bool: + return any(part in EXCLUDED_DIRECTORIES or part.startswith(".xcode-derived") for part in _parts(path)[:-1]) + + +def classification(path: str | Path) -> str: + """Return the canonical source class for a repository-relative path.""" + value = normalise(path).lower() + parts = _parts(value) + filename = parts[-1] if parts else value + stem = filename.rsplit(".", 1)[0] + if excluded_directory(value): + if any(part in {"vendor", "vendors", "third_party", "third-party", "node_modules", "packages"} for part in parts): + return "DEPENDENCY" + if any(part in {"coverage", "coverage-html", "htmlcov", "artifacts", "reports", "report"} for part in parts): + return "COVERAGE_ARTIFACT" + return "GENERATED" + if filename in {"coverage.xml", "coverage.json", "lcov.info", "cobertura.xml"} or stem.startswith(("coverage-", "coverage_")): + return "COVERAGE_ARTIFACT" + if any(part in {"fixture", "fixtures", "__fixtures__", "testdata", "test-data", "mock", "mocks", "__mocks__"} for part in parts): + return "FIXTURE" + if any(part in {"verification", "verify", "verification-output", "verification_output"} for part in parts) or stem.startswith(("validate_", "verify_", "check_")): + return "VERIFICATION" + if any(part in {"test", "tests", "spec", "specs", "__tests__"} for part in parts) or stem.startswith(("test_", "spec_")) or stem.endswith(("_test", "_spec", ".test", ".spec")): + return "TEST" + if any(part in {"sample", "samples", "example", "examples", "demo", "demos"} for part in parts): + return "SAMPLE" + return "PRODUCT_SOURCE" + + +def language_for(path: str | Path) -> str | None: + return LANGUAGE_BY_EXTENSION.get(Path(normalise(path)).suffix.lower()) + + +def is_analyzable_product(path: str | Path) -> bool: + return classification(path) == "PRODUCT_SOURCE" and language_for(path) is not None + + +def primary_languages(root: Path) -> tuple[str, ...]: + """Discover dominant product languages by nonblank source lines. + + Counting only recognised product files makes the decision independent of + documentation, generated output and auxiliary tooling. Ties deliberately + retain every primary language instead of choosing arbitrarily. + """ + counts: dict[str, int] = {} + for path in sorted(root.rglob("*")): + if not path.is_file(): + continue + relative = path.relative_to(root) + language = language_for(relative) + if language is None or classification(relative) != "PRODUCT_SOURCE": + continue + try: + lines = sum(1 for line in path.read_text(encoding="utf-8", errors="replace").splitlines() if line.strip()) + except OSError: + continue + counts[language] = counts.get(language, 0) + lines + if not counts: + return () + maximum = max(counts.values()) + return tuple(sorted(language for language, count in counts.items() if count == maximum)) diff --git a/tests/test_cli.py b/tests/test_cli.py index 31fdcca..fe0ee5a 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.0.5", version["cliVersion"]) - self.assertEqual("1.0.5", version["runtimeVersion"]) + self.assertEqual("1.1.0", version["cliVersion"]) + self.assertEqual("1.1.0", version["runtimeVersion"]) self.assertEqual("1.0.0", version["schemaVersion"]) self.assertEqual("1", version["generation"]) diff --git a/tests/test_code_size.py b/tests/test_code_size.py index 097f140..16e326a 100644 --- a/tests/test_code_size.py +++ b/tests/test_code_size.py @@ -56,6 +56,12 @@ def test_code_size_excludes_dependency_vendor_and_generated_output(self) -> None excluded = ("obj/", "bin/", ".release-venv/", "node_modules/", "vendor/", "third_party/", "generated/", "artifacts/") self.assertTrue(all(not item["path"].startswith(excluded) for item in result["files"])) + def test_code_size_excludes_root_coverage_artifacts_without_hiding_coverage_capability_inputs(self) -> None: + (self.root / "coverage.xml").write_text("\n", encoding="utf-8") + (self.root / "lcov.info").write_text("TN:\n", encoding="utf-8") + result = analyze(self.root) + self.assertFalse(any(item["path"] in {"coverage.xml", "lcov.info"} for item in result["files"])) + def test_evidence_digest_is_stable_for_same_repository_and_configuration(self) -> None: configuration = RuntimeConfiguration.load({"capabilities": {"code_size": {"enabled": True}}}) first = Runtime().execute(self.root, configuration).evidence["integrity"]["contentDigest"] diff --git a/tests/test_complexity.py b/tests/test_complexity.py index d66f375..3620c4e 100644 --- a/tests/test_complexity.py +++ b/tests/test_complexity.py @@ -6,7 +6,7 @@ from pathlib import Path from tde_runtime import Runtime, RuntimeConfiguration from tde_runtime.code_size import analyze as analyze_code_size -from tde_runtime.complexity import _portable_native_output, analyze, classify_path +from tde_runtime.complexity import _lizard, _portable_native_output, analyze, classify_path from tde_cli.main import ExitCode, main class ComplexityTests(unittest.TestCase): @@ -63,10 +63,10 @@ def test_default_policy_keeps_critical_test_complexity_visible_without_blocking_ def test_missing_analyzer_and_unsupported_version_block(self): with tempfile.TemporaryDirectory() as directory: root=Path(directory) - with patch("tde_runtime.analyzer_discovery.shutil.which",return_value=None): + (root / "sample.py").write_text("def sample():\n return 1\n", encoding="utf-8") + with patch("tde_runtime.complexity.discover", return_value={"status": "ANALYZER_NOT_FOUND", "limitation": {"id": "analyzer.radon.unavailable", "description": "radon unavailable", "cause": "analyzer unavailable"}}): self.assertEqual("ANALYZER_NOT_FOUND",analyze(root)["status"]) - with patch("tde_runtime.analyzer_discovery.shutil.which",return_value="radon"), patch("tde_runtime.analyzer_discovery.subprocess.run") as run: - run.return_value.stdout="5.0.0" + with patch("tde_runtime.complexity.discover", return_value={"status": "ANALYZER_NOT_FOUND", "limitation": {"id": "analyzer.radon.unsupported_version", "description": "unsupported", "cause": "unsupported analyzer version"}}): self.assertEqual("analyzer.radon.unsupported_version",analyze(root)["limitations"][0]["id"]) def test_native_output_is_relative_and_deterministic(self): @@ -85,3 +85,45 @@ def test_configuration_discovery_persistence_query_and_report(self): assessment=json.loads(stream.getvalue()); self.assertEqual("QUALIFIED",assessment["runtimeQualification"]["level"]) stream=StringIO(); self.assertEqual(ExitCode.SUCCESS,main(["--format","json","query",str(root),"--resource","findings"],stream)); self.assertGreater(json.loads(stream.getvalue())["queryEvidence"]["resultCount"],0) stream=StringIO(); self.assertEqual(ExitCode.SUCCESS,main(["--format","markdown","report","--capability","complexity",str(root)],stream)); self.assertIn("# Complexity Report",stream.getvalue()) + + def test_lizard_normalizes_typescript_symbols_with_provenance(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = root / "src"; source.mkdir() + target = source / "service.ts" + target.write_text("export function branch(value: boolean) { if (value) return 1; return 0; }\n", encoding="utf-8") + native = f'1,2,12,1,1,"branch@1@{target}","{target}",branch,"branch(value: boolean)",1,1\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], ("TypeScript",), 10) + self.assertEqual("VALID", result["status"]) + self.assertEqual("TypeScript", result["symbols"][0]["language"]) + self.assertEqual(2, result["symbols"][0]["complexity"]) + self.assertEqual("complexity.lizard", result["symbols"][0]["adapterId"]) + self.assertEqual("lizard==1.23.0", result["adapter"]["analyzer"]["package"]) + + def test_primary_language_prevents_auxiliary_python_from_qualifying_csharp(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "Program.cs").write_text("class Program {\n static int Branch(bool value) {\n if (value) return 1;\n return 0;\n }\n}\n", encoding="utf-8") + (root / "tools").mkdir() + (root / "tools" / "helper.py").write_text("def branch(value): return 1\n", encoding="utf-8") + with patch("tde_runtime.complexity._lizard") as lizard: + lizard.return_value = {"status": "VALID", "symbols": [{"path": "Program.cs", "classification": "PRODUCT_SOURCE", "language": "C#", "name": "Branch", "type": "function", "line": 1, "endLine": 1, "complexity": 2, "adapterId": "complexity.lizard", "toolId": "lizard"}], "adapter": {"id": "complexity.lizard", "version": "1.1.0", "analyzer": {"id": "lizard", "version": "1.23.0"}, "rawOutput": "", "rawOutputHash": "sha256:test"}} + result = analyze(root) + self.assertEqual(["C#"], result["primaryLanguages"]) + self.assertEqual(["C#"], sorted({symbol["language"] for symbol in result["symbols"]})) + + def test_coverage_and_generated_paths_do_not_contaminate_complexity_input(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "app.py").write_text("def app():\n return 1\n", encoding="utf-8") + (root / "coverage").mkdir(); (root / "coverage" / "generated.py").write_text("def generated():\n return 1\n", encoding="utf-8") + (root / "verification").mkdir(); (root / "verification" / "verify.py").write_text("def verify():\n return 1\n", encoding="utf-8") + with patch("tde_runtime.complexity._radon") as radon: + radon.return_value = {"status": "VALID", "symbols": [{"path": "app.py", "classification": "PRODUCT_SOURCE", "language": "Python", "name": "app", "type": "function", "line": 1, "endLine": 2, "complexity": 1, "adapterId": "complexity.radon", "toolId": "radon"}], "adapter": {"id": "complexity.radon", "version": "1.1.0", "analyzer": {"id": "radon", "version": "6.0.1"}, "rawOutput": "{}", "rawOutputHash": "sha256:test"}} + result = analyze(root) + supplied = [path.relative_to(root).as_posix() for path in radon.call_args.args[1]] + self.assertEqual(["app.py"], supplied) + self.assertEqual("VALID", result["status"]) diff --git a/tests/test_coverage.py b/tests/test_coverage.py index cac2736..c7766f5 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.0.5"], "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.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"}]} 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 d21bcc7..e14d635 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.0.5"], "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.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"}]} 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 2837cac..108213d 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.0.5"], + "supportedRuntimeVersions": ["1.1.0"], "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.0.5", item["schema"]["runtimeVersion"]) + self.assertEqual("1.1.0", 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 2233ebe..3cdb392 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.0.5", result.context.runtime_version) + self.assertEqual("1.1.0", 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"]) From cce24167af585f5584332526eb1c2d6289270e17 Mon Sep 17 00:00:00 2001 From: pcvantol Date: Wed, 29 Jul 2026 09:53:45 +0200 Subject: [PATCH 2/3] Fix complexity analyzer qualification --- tools/run_complexity_qualification.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tools/run_complexity_qualification.py b/tools/run_complexity_qualification.py index 1d170ff..e894d0d 100644 --- a/tools/run_complexity_qualification.py +++ b/tools/run_complexity_qualification.py @@ -68,7 +68,10 @@ def main() -> int: python, tde = executable(venv, "python"), executable(venv, "tde") isolated_env = {**os.environ, "PATH": str(Path(tde).parent) + os.pathsep + os.environ.get("PATH", "")} run([python, "-m", "pip", "install", "--disable-pip-version-check", "--no-deps", str(wheel)]) - missing_analyzer = run([tde, "--format", "json", "assess", "--capability", "complexity", str(fixture)], expected={3}, env={**os.environ, "PATH": str(Path(tde).parent)}) + # The isolated wheel is intentionally installed with --no-deps. The + # public CLI must report the documented ANALYZER_NOT_FOUND exit code, + # rather than silently accepting absent analyzer evidence. + missing_analyzer = run([tde, "--format", "json", "assess", "--capability", "complexity", str(fixture)], expected={5}, env={**os.environ, "PATH": str(Path(tde).parent)}) # The negative case must not become the persisted record used by the # subsequent Query/report qualification. shutil.rmtree(fixture / ".tde", ignore_errors=True) @@ -110,8 +113,8 @@ def main() -> int: "candidateSha": os.environ.get("GITHUB_SHA", "local"), "workflow": os.environ.get("GITHUB_WORKFLOW", "local"), "workflowRun": os.environ.get("GITHUB_RUN_ID"), "operatingSystem": platform.platform(), "pythonVersion": platform.python_version(), "packageVersion": json.loads(commands["version"]["_raw"])["cliVersion"], - "wheelChecksum": digest(wheel), "capability": {"id": "complexity", "version": "0.1.0"}, - "adapter": {"id": "complexity.radon", "version": "0.1.0"}, "analyzer": analyzer, + "wheelChecksum": digest(wheel), "capability": {"id": "complexity", "version": "1.1.0"}, + "adapter": {"id": "complexity.radon", "version": "1.1.0"}, "analyzer": analyzer, "fixture": {"path": "fixtures/complexity-cross-platform", "digest": tree_digest(FIXTURE)}, "configurationDigest": evidence["configurationDigest"], "evidenceId": assessment["evidenceId"], "commands": {name: command_record(command) for name, command in commands.items()}, From b07c9da74a4933214eeedb3743bfd19bdcfe546c Mon Sep 17 00:00:00 2001 From: pcvantol Date: Wed, 29 Jul 2026 16:43:22 +0200 Subject: [PATCH 3/3] Normalize platform-specific complexity provenance --- tools/compare_complexity_qualification.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/tools/compare_complexity_qualification.py b/tools/compare_complexity_qualification.py index ae28457..50836fa 100644 --- a/tools/compare_complexity_qualification.py +++ b/tools/compare_complexity_qualification.py @@ -10,13 +10,21 @@ def normalize(value: object) -> object: - """Remove the documented non-deterministic execution envelope only.""" + """Remove the documented non-deterministic execution envelope only. + + Analyzer provenance intentionally records the runner platform and absolute + executable path. Both remain in each qualification record for audit, but + neither is an analytical result and therefore cannot participate in a + cross-platform equivalence comparison. + """ if isinstance(value, list): return [normalize(item) for item in value] if isinstance(value, dict): - return {key: normalize(item) for key, item in value.items() - if key not in {"executionTiming", "executionId", "qualificationId", "evaluatedAt", "executionDurationMs", - "targetEntityId", "measurementId", "qualificationReference"}} + excluded_keys = { + "executionTiming", "executionId", "qualificationId", "evaluatedAt", "executionDurationMs", + "targetEntityId", "measurementId", "qualificationReference", "executable", "platform", + } + return {key: normalize(item) for key, item in value.items() if key not in excluded_keys} return value