diff --git a/.github/workflows/flow-quality.yml b/.github/workflows/flow-quality.yml index 6d1278c..08fdc3e 100644 --- a/.github/workflows/flow-quality.yml +++ b/.github/workflows/flow-quality.yml @@ -50,13 +50,164 @@ jobs: - name: Validate PyUVM fixture evidence run: tests/check_fixture_pyuvm_evidence.sh + - name: Verify reusable and dedicated coverage sources + run: | + make -C tests/fixture-module \ + FLOW_ROOT="${GITHUB_WORKSPACE}" \ + COVERAGE_QUALIFICATION_SOURCE=pyuvm_open_source \ + FLOW_DEPENDENCIES_coverage_qualification=pyuvm_open_source \ + open-coverage + grep -Fq '"source": "pyuvm_open_source"' \ + tests/fixture-module/reports/coverage_qualification/summary.json + make -C tests/fixture-module \ + FLOW_ROOT="${GITHUB_WORKSPACE}" \ + COVERAGE_QUALIFICATION_SOURCE=dedicated \ + FLOW_DEPENDENCIES_coverage_qualification= \ + open-coverage + grep -Fq '"source": "dedicated"' \ + tests/fixture-module/reports/coverage_qualification/summary.json + - name: Verify SystemVerilog assertion failure propagation run: tests/test_pyuvm_assertion_failure.sh + - name: Verify unreachable formal cover failure propagation + run: tests/test_coverage_unreachable_formal.sh + + - name: Run complete multi-module fixture flow + run: | + make -C tests/fixture-multi-module \ + FLOW_ROOT="${GITHUB_WORKSPACE}" clean all-modules MODULE_JOBS=2 + + - name: Run complete parameter-profile fixture flow + run: | + make -C tests/fixture-parameter-profiles \ + FLOW_ROOT="${GITHUB_WORKSPACE}" clean all-profiles PROFILE_JOBS=4 + + - name: Validate parameter-profile fixture evidence + run: tests/check_fixture_parameter_profile_evidence.sh + + - name: Generate release evidence for the complete fixture + run: | + make -C tests/fixture-module \ + FLOW_ROOT="${GITHUB_WORKSPACE}" \ + DISABLED_FLOWS="openroad vcs_sim pyuvm_commercial vc_lint vc_cdc sg_cdc sg_dft vc_lp synopsys_synthesis synopsys_primetime synopsys_primepower" \ + MODULE_REVISION="${GITHUB_SHA}" \ + METHODOLOGY_REVISION="${GITHUB_SHA}" \ + release-manifest release-manifest-validate + + - name: Validate complete fixture release evidence contents + run: EXPECT_CLEAN_RELEASE=true tests/check_fixture_release_manifest.sh + - name: Upload fixture reports if: always() uses: actions/upload-artifact@v4 with: name: mosaic-flow-fixture-reports - path: tests/fixture-module/reports/ + path: | + tests/fixture-module/reports/ + tests/fixture-multi-module/reports/ + tests/fixture-parameter-profiles/reports/ + if-no-files-found: error + + multi-module-container: + name: Containerized multi-module orchestration + runs-on: ubuntu-24.04 + timeout-minutes: 10 + container: python:3.12-slim-bookworm@sha256:782412e85d0f0984994c290652577d4018aff08145c85b262bb63dc0c7522254 + + steps: + - name: Install container test dependencies + run: | + apt-get update + apt-get install --yes --no-install-recommends findutils git make + rm -rf /var/lib/apt/lists/* + + - name: Check out mosaic-flow + uses: actions/checkout@v4 + + - name: Run multi-module orchestration tests + run: | + tests/test_multi_module.sh + tests/test_parameter_profiles.sh + tests/test_coverage_qualification.sh + tests/test_qualification_campaigns.sh + tests/test_static_intent.sh + + - name: Run containerized release-manifest fixture + run: FIXTURE_CONTEXT=container tests/test_release_manifest.sh + + openroad-container: + name: Containerized Nangate45 physical fixture + runs-on: ubuntu-24.04 + timeout-minutes: 45 + + steps: + - name: Check out mosaic-flow + uses: actions/checkout@v4 + + - name: Cache pinned OpenROAD image layers + uses: actions/cache@v4 + with: + path: ~/.cache/mosaic/openroad-images + key: openroad-image-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('config/tool-versions.env') }} + + - name: Prepare pinned OpenROAD image + run: ci/cache_openroad_image.sh + + - name: Run Nangate45 fixture + run: | + make -C tests/fixture-module \ + FLOW_ROOT="${GITHUB_WORKSPACE}" \ + OPENROAD_EXECUTION_MODE=container \ + open-physical + + - name: Validate physical evidence and ownership + run: | + python3 - <<'PY' + import json + import pathlib + + evidence = json.loads( + pathlib.Path("tests/fixture-module/reports/openroad/evidence.json").read_text() + ) + assert evidence["status"] == "PASS" + assert evidence["design"]["platform"] == "nangate45" + assert evidence["execution"]["mode"] == "container" + assert evidence["execution"]["image"]["reference"].endswith( + "@" + evidence["execution"]["image"]["digest"] + ) + assert {item["name"] for item in evidence["artifacts"]} == { + "final_def", "final_gds", "final_netlist", "final_odb", "final_sdc" + } + assert all(item["passed"] for item in evidence["metrics"]) + PY + if find tests/fixture-module/work/openroad -not -user "$(id -un)" -print -quit | grep -q .; then + echo "Containerized OpenROAD produced files owned by another user" >&2 + exit 1 + fi + + - name: Index physical evidence in release manifest + run: | + make -C tests/fixture-module \ + FLOW_ROOT="${GITHUB_WORKSPACE}" \ + DISABLED_FLOWS="verible_lint verible_format slang_elaboration verilator_lint yosys_synthesis symbiyosys_formal eqy_equivalence verilator_sim pyuvm_open_source coverage_qualification negative_qualification four_state_qualification static_intent vcs_sim pyuvm_commercial vc_lint vc_cdc sg_cdc sg_dft vc_lp synopsys_synthesis synopsys_primetime synopsys_primepower" \ + MODULE_REVISION="${GITHUB_SHA}" \ + METHODOLOGY_REVISION="${GITHUB_SHA}" \ + RELEASE_EXECUTION_CONTEXT=container \ + RELEASE_TECHNOLOGY=nangate45 \ + release-manifest release-manifest-validate + grep -Fq 'reports/openroad/evidence.json' \ + tests/fixture-module/reports/release_manifest/container/manifest.json + + - name: Upload physical fixture evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: mosaic-flow-openroad-nangate45 + path: | + tests/fixture-module/reports/openroad/ + tests/fixture-module/reports/release_manifest/container/ + tests/fixture-module/work/openroad/results/ + tests/fixture-module/work/openroad/reports/ + tests/fixture-module/work/openroad/logs/ if-no-files-found: error diff --git a/.gitignore b/.gitignore index 95b6422..e938ae6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,9 @@ tests/fixture-module/work/ tests/fixture-module/reports/* !tests/fixture-module/reports/.gitkeep +tests/fixture-multi-module/work/ +tests/fixture-multi-module/reports/ +tests/fixture-parameter-profiles/work/ +tests/fixture-parameter-profiles/reports/ +__pycache__/ +*.py[cod] diff --git a/README.md b/README.md index df75b58..d6656d2 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,14 @@ methodology without copying flow scripts or changing module RTL. - Make targets and flow dependency orchestration - Open-source and Synopsys tool adapters - Optional PyUVM verification with open-source, VCS, and Xcelium backends +- Single-module and manifest-based multi-module project orchestration +- Named parameter-profile qualification across elaboration and implementation +- Declarative HDL and formal coverage qualification with reviewed exclusions +- Declarative negative-test and four-state qualification with explicit controls +- Declarative, license-independent SDC and UPF intent validation +- Reproducible local or pinned-container ORFS implementation with declarative physical evidence - Flow selection, statuses, reports, and quality gates +- Schema-validated release evidence with input hashes and tool identities - Pinned open-source tool installers and versions - Methodology CI and its independent fixture module @@ -33,6 +40,8 @@ reference, and links to: - PyUVM, SVA, and coverage reuse across verification environments - Every open-source and commercial flow - Results, quality gates, waivers, and release evidence +- Portable SDC and UPF intent checks and their signoff boundary +- Containerized OpenROAD execution and physical evidence policy - Methodology development, qualification, and release procedures ## Consumer quick start @@ -56,9 +65,18 @@ include $(FLOW_ROOT)/config/tools.mk include $(FLOW_ROOT)/mk/module.mk ``` +A repository containing multiple independently qualified modules instead uses +the project bootstrap and a validated module manifest: + +```make +include $(FLOW_ROOT)/mk/project.mk +``` + See [Getting started](docs/getting-started.md) for the complete module contract -and [Configuration](docs/configuration.md) for flow selection, dependencies, -tool overrides, and technology setup. +and [Multi-module projects](docs/multi-module-projects.md) for registry, +selection, concurrent execution, and CI matrix guidance. Parameterizable RTL +can use [Parameter profiles](docs/parameter-profiles.md) to qualify independent +elaborations without duplicating flow scripts. ## Methodology validation @@ -69,6 +87,9 @@ complete portable fixture flow: ci/install_ci_tools.sh "$HOME/.local" PATH="$HOME/.local/bin:$PATH" ci/check_flow_quality.sh make -C tests/fixture-module FLOW_ROOT="$PWD" clean open-source +make -C tests/fixture-multi-module FLOW_ROOT="$PWD" clean all-modules MODULE_JOBS=2 +make -C tests/fixture-parameter-profiles FLOW_ROOT="$PWD" clean all-profiles PROFILE_JOBS=4 +tests/test_release_manifest.sh ``` GitHub Actions runs the same validation on pushes and pull requests. Commercial diff --git a/VERSION b/VERSION index 6e8bf73..a3df0a6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0 +0.8.0 diff --git a/ci/cache_openroad_image.sh b/ci/cache_openroad_image.sh new file mode 100755 index 0000000..7ef0922 --- /dev/null +++ b/ci/cache_openroad_image.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +set -euo pipefail + +flow_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=../config/tool-versions.env +source "${flow_root}/config/tool-versions.env" + +runtime="${OPENROAD_CONTAINER_RUNTIME:-docker}" +image="${OPENROAD_ORFS_IMAGE:-${ORFS_IMAGE_REPOSITORY}@${ORFS_IMAGE_DIGEST}}" +cache_root="${OPENROAD_IMAGE_CACHE_ROOT:-${HOME}/.cache/mosaic/openroad-images}" +archive="${cache_root}/${ORFS_IMAGE_DIGEST#sha256:}.tar" + +if ! command -v "${runtime}" >/dev/null 2>&1; then + echo "OpenROAD container runtime is unavailable: ${runtime}" >&2 + exit 2 +fi +if [[ ! "${image}" =~ @sha256:[0-9a-f]{64}$ ]]; then + echo "OpenROAD image cache requires an immutable @sha256 reference: ${image}" >&2 + exit 2 +fi + +if ! "${runtime}" image inspect "${image}" >/dev/null 2>&1 && [[ -s "${archive}" ]]; then + echo "Loading pinned OpenROAD image from ${archive}" + "${runtime}" load --input "${archive}" +fi + +if ! "${runtime}" image inspect "${image}" >/dev/null 2>&1; then + echo "Pulling pinned OpenROAD image: ${image}" + "${runtime}" pull "${image}" +else + echo "Pinned OpenROAD image is available: ${image}" +fi + +if [[ ! -s "${archive}" ]]; then + mkdir -p "${cache_root}" + temporary_archive="${archive}.tmp.$$" + trap 'rm -f "${temporary_archive:-}"' EXIT + echo "Saving pinned OpenROAD image layers to ${archive}" + "${runtime}" save --output "${temporary_archive}" "${image}" + mv "${temporary_archive}" "${archive}" + trap - EXIT +fi diff --git a/ci/check_flow_quality.sh b/ci/check_flow_quality.sh index 733b9ed..135689a 100755 --- a/ci/check_flow_quality.sh +++ b/ci/check_flow_quality.sh @@ -5,7 +5,7 @@ flow_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "${flow_root}" mapfile -d '' shell_files < <( - find ci flows tests -path 'tests/fixture-module/work' -prune -o \ + find ci flows tests -type d \( -name work -o -name reports \) -prune -o \ -type f -name '*.sh' -print0 | sort -z ) mapfile -d '' workflow_files < <(find .github/workflows -type f \( -name '*.yml' -o -name '*.yaml' \) -print0 | sort -z) @@ -16,6 +16,18 @@ done shellcheck --external-sources "${shell_files[@]}" actionlint "${workflow_files[@]}" +python3 -m py_compile ci/module_manifest.py +python3 -m py_compile ci/parameter_profiles.py +python3 -m py_compile ci/release_manifest.py +python3 -m py_compile ci/coverage_qualification.py +python3 -m py_compile ci/qualification_campaign.py +python3 -m py_compile ci/static_intent.py +python3 -m py_compile ci/openroad_evidence.py +python3 -m json.tool schemas/release-evidence-v1.schema.json >/dev/null +python3 -m json.tool schemas/coverage-policy-v1.schema.json >/dev/null +python3 -m json.tool schemas/qualification-campaigns-v1.schema.json >/dev/null +python3 -m json.tool schemas/static-intent-v1.schema.json >/dev/null +python3 -m json.tool schemas/openroad-evidence-policy-v1.schema.json >/dev/null if ! grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' VERSION; then echo "VERSION must contain a semantic version such as 1.2.3" >&2 @@ -36,6 +48,8 @@ required_version_keys=( SHELLCHECK_SHA256 ACTIONLINT_VERSION ACTIONLINT_SHA256 + ORFS_IMAGE_REPOSITORY + ORFS_IMAGE_DIGEST ) source config/tool-versions.env for version_key in "${required_version_keys[@]}"; do @@ -45,6 +59,11 @@ for version_key in "${required_version_keys[@]}"; do fi done +if [[ ! "${ORFS_IMAGE_DIGEST}" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "ORFS_IMAGE_DIGEST must be an immutable sha256 digest" >&2 + exit 1 +fi + for requirement in \ "pyuvm==${PYUVM_VERSION}" \ "cocotb==${COCOTB_VERSION}" \ @@ -69,4 +88,11 @@ while IFS= read -r -d '' data_file; do done < <(find . -path './.git' -prune -o -type f \( -name '*.tcl' -o -name '*.mk' -o -name '*.env' \) -print0) tests/test_quality_gate.sh +tests/test_multi_module.sh +tests/test_parameter_profiles.sh +tests/test_release_manifest.sh +tests/test_coverage_qualification.sh +tests/test_qualification_campaigns.sh +tests/test_static_intent.sh +tests/test_openroad_evidence.sh echo "mosaic-flow static quality checks passed" diff --git a/ci/clean.sh b/ci/clean.sh index 9e73329..091ffe1 100755 --- a/ci/clean.sh +++ b/ci/clean.sh @@ -1,7 +1,10 @@ #!/usr/bin/env bash set -euo pipefail -rm -rf -- "${MODULE_ROOT}/work" -if [[ -d "${MODULE_ROOT}/reports" ]]; then - find "${MODULE_ROOT}/reports" -mindepth 1 ! -name .gitkeep -delete +work_root="${WORK_DIR:-${MODULE_ROOT}/work}" +report_root="${REPORT_DIR:-${MODULE_ROOT}/reports}" + +rm -rf -- "${work_root}" +if [[ -d "${report_root}" ]]; then + find "${report_root}" -mindepth 1 ! -name .gitkeep -delete fi diff --git a/ci/coverage_qualification.py b/ci/coverage_qualification.py new file mode 100755 index 0000000..dc2a17b --- /dev/null +++ b/ci/coverage_qualification.py @@ -0,0 +1,468 @@ +#!/usr/bin/env python3 +"""Validate declarative HDL coverage requirements and emit release evidence.""" + +from __future__ import annotations + +import argparse +import fnmatch +import json +import re +import sys +from dataclasses import dataclass +from pathlib import Path, PurePosixPath +from typing import Any + + +SCHEMA = "mosaic-coverage-policy-v1" +METRICS = {"line", "branch", "toggle", "user"} +COVERPOINT_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_$]*$") +NATIVE_RECORD = re.compile(r"^C '(.*)'\s+([0-9]+)$") + + +class QualificationError(ValueError): + """Describe a policy or evidence problem that the module owner can fix.""" + + +@dataclass(frozen=True) +class CoverageRecord: + """One normalized coverage counter from native or LCOV evidence.""" + + source: str + line: int + metric: str + name: str + hits: int + origin: str + + +def object_with_keys(value: Any, label: str, allowed: set[str], required: set[str]) -> dict[str, Any]: + """Require an object with an exact, documented key vocabulary.""" + if not isinstance(value, dict): + raise QualificationError(f"{label} must be an object") + unknown = set(value) - allowed + missing = required - set(value) + if unknown: + raise QualificationError(f"{label} has unknown fields: {', '.join(sorted(unknown))}") + if missing: + raise QualificationError(f"{label} is missing fields: {', '.join(sorted(missing))}") + return value + + +def nonempty_string(value: Any, label: str) -> str: + """Require one nonempty string without silently coercing values.""" + if not isinstance(value, str) or not value.strip(): + raise QualificationError(f"{label} must be a nonempty string") + return value.strip() + + +def load_policy(path: Path, module_root: Path) -> dict[str, Any]: + """Load and strictly validate the versioned module policy.""" + try: + policy = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError as error: + raise QualificationError(f"Coverage policy is missing: {path}") from error + except json.JSONDecodeError as error: + raise QualificationError( + f"Coverage policy is invalid JSON at line {error.lineno}, column {error.colno}: {error.msg}" + ) from error + + object_with_keys( + policy, + "policy", + {"schema", "scope", "thresholds", "coverpoints", "exclusions", "formal"}, + {"schema", "thresholds", "coverpoints", "exclusions", "formal"}, + ) + if policy["schema"] != SCHEMA: + raise QualificationError(f"policy.schema must be {SCHEMA}") + + scope = object_with_keys(policy.get("scope", {}), "policy.scope", {"include", "exclude"}, set()) + for field in ("include", "exclude"): + values = scope.get(field, []) + if not isinstance(values, list) or not all(isinstance(item, str) and item for item in values): + raise QualificationError(f"policy.scope.{field} must be an array of nonempty strings") + if "include" in scope and not scope["include"]: + raise QualificationError("policy.scope.include must not be empty") + + thresholds = object_with_keys(policy["thresholds"], "policy.thresholds", METRICS, set()) + for metric, threshold in thresholds.items(): + if isinstance(threshold, bool) or not isinstance(threshold, (int, float)) or not 0 <= threshold <= 100: + raise QualificationError(f"thresholds.{metric} must be a number from 0 through 100") + + coverpoints = policy["coverpoints"] + if not isinstance(coverpoints, list): + raise QualificationError("policy.coverpoints must be an array") + seen_coverpoints: set[str] = set() + for index, item in enumerate(coverpoints): + item = object_with_keys(item, f"coverpoints[{index}]", {"name", "minimum_hits"}, {"name", "minimum_hits"}) + name = nonempty_string(item["name"], f"coverpoints[{index}].name") + if not COVERPOINT_NAME.fullmatch(name): + raise QualificationError(f"coverpoints[{index}].name is not a valid SystemVerilog label") + if name in seen_coverpoints: + raise QualificationError(f"Duplicate coverpoint requirement: {name}") + seen_coverpoints.add(name) + minimum = item["minimum_hits"] + if isinstance(minimum, bool) or not isinstance(minimum, int) or minimum < 1: + raise QualificationError(f"coverpoints[{index}].minimum_hits must be a positive integer") + + exclusions = policy["exclusions"] + if not isinstance(exclusions, list): + raise QualificationError("policy.exclusions must be an array") + seen_exclusions: set[str] = set() + for index, item in enumerate(exclusions): + item = object_with_keys( + item, + f"exclusions[{index}]", + {"source", "metric", "reason", "owner", "scope"}, + {"source", "metric", "reason", "owner", "scope"}, + ) + source = nonempty_string(item["source"], f"exclusions[{index}].source") + source_path = PurePosixPath(source) + if source_path.is_absolute() or ".." in source_path.parts: + raise QualificationError(f"exclusions[{index}].source must be module-relative") + if not (module_root / source_path).is_file(): + raise QualificationError(f"exclusions[{index}].source does not exist: {source}") + if item["metric"] not in METRICS: + raise QualificationError(f"exclusions[{index}].metric must name a supported metric") + nonempty_string(item["reason"], f"exclusions[{index}].reason") + nonempty_string(item["owner"], f"exclusions[{index}].owner") + item_scope = object_with_keys( + item["scope"], f"exclusions[{index}].scope", {"line_start", "line_end", "name"}, set() + ) + if not item_scope: + raise QualificationError(f"exclusions[{index}].scope must not be empty") + for field in ("line_start", "line_end"): + if field in item_scope and ( + isinstance(item_scope[field], bool) + or not isinstance(item_scope[field], int) + or item_scope[field] < 1 + ): + raise QualificationError(f"exclusions[{index}].scope.{field} must be a positive integer") + if "line_end" in item_scope and "line_start" not in item_scope: + raise QualificationError(f"exclusions[{index}].scope.line_end requires line_start") + if item_scope.get("line_end", item_scope.get("line_start", 1)) < item_scope.get("line_start", 1): + raise QualificationError(f"exclusions[{index}] has an inverted line range") + if "name" in item_scope: + nonempty_string(item_scope["name"], f"exclusions[{index}].scope.name") + fingerprint = json.dumps(item, sort_keys=True) + if fingerprint in seen_exclusions: + raise QualificationError(f"Duplicate exclusion at index {index}") + seen_exclusions.add(fingerprint) + + formal = object_with_keys(policy["formal"], "policy.formal", {"required"}, {"required"}) + if not isinstance(formal["required"], bool): + raise QualificationError("policy.formal.required must be true or false") + return policy + + +def normalize_source(source: str, module_root: Path) -> str: + """Normalize simulator paths to a module-relative POSIX spelling when possible.""" + path = Path(source) + if path.is_absolute(): + try: + return path.resolve().relative_to(module_root.resolve()).as_posix() + except ValueError: + return path.as_posix() + text = PurePosixPath(source).as_posix() + while text.startswith("./"): + text = text[2:] + return text + + +def parse_native(path: Path, module_root: Path) -> list[CoverageRecord]: + """Parse Verilator's tagged native counters without assuming page names.""" + if not path.is_file(): + return [] + records: list[CoverageRecord] = [] + for line_number, raw_line in enumerate(path.read_text(encoding="latin-1").splitlines(), 1): + if not raw_line.startswith("C "): + continue + match = NATIVE_RECORD.fullmatch(raw_line) + if not match: + raise QualificationError(f"Malformed native coverage record at {path}:{line_number}") + tagged, hits_text = match.groups() + fields: dict[str, str] = {} + for part in tagged.split("\x01"): + if "\x02" in part: + key, value = part.split("\x02", 1) + fields[key] = value + metric = fields.get("t", "") + if metric not in METRICS: + continue + try: + source_line = int(fields.get("l", "0")) + except ValueError as error: + raise QualificationError(f"Invalid native source line at {path}:{line_number}") from error + records.append( + CoverageRecord( + normalize_source(fields.get("f", ""), module_root), + source_line, + metric, + fields.get("o", ""), + int(hits_text), + "verilator_native", + ) + ) + return records + + +def parse_lcov(path: Path, module_root: Path) -> list[CoverageRecord]: + """Parse LCOV lines and split Verilator transition branches from control branches.""" + if not path.is_file(): + return [] + records: list[CoverageRecord] = [] + source = "" + for line_number, raw_line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): + if raw_line.startswith("SF:"): + source = normalize_source(raw_line[3:], module_root) + elif raw_line.startswith("DA:"): + if not source: + raise QualificationError(f"LCOV DA record precedes SF at {path}:{line_number}") + fields = raw_line[3:].split(",") + if len(fields) < 2: + raise QualificationError(f"Malformed LCOV DA record at {path}:{line_number}") + records.append(CoverageRecord(source, int(fields[0]), "line", "", int(fields[1]), "lcov")) + elif raw_line.startswith("BRDA:"): + if not source: + raise QualificationError(f"LCOV BRDA record precedes SF at {path}:{line_number}") + fields = raw_line[5:].split(",", 3) + if len(fields) != 4: + raise QualificationError(f"Malformed LCOV BRDA record at {path}:{line_number}") + metric = "toggle" if "->" in fields[2] else "branch" + hits = 0 if fields[3] == "-" else int(fields[3]) + records.append(CoverageRecord(source, int(fields[0]), metric, fields[2], hits, "lcov")) + return records + + +def source_selected(source: str, policy: dict[str, Any]) -> bool: + """Apply optional module-owned include and exclude globs.""" + scope = policy.get("scope", {}) + includes = scope.get("include", ["*"]) + excludes = scope.get("exclude", []) + return any(fnmatch.fnmatchcase(source, pattern) for pattern in includes) and not any( + fnmatch.fnmatchcase(source, pattern) for pattern in excludes + ) + + +def exclusion_matches(exclusion: dict[str, Any], record: CoverageRecord) -> bool: + """Match one reviewed exclusion against one normalized evidence counter.""" + if exclusion["source"] != record.source or exclusion["metric"] != record.metric: + return False + scope = exclusion["scope"] + start = scope.get("line_start") + end = scope.get("line_end", start) + if start is not None and not start <= record.line <= end: + return False + return "name" not in scope or scope["name"] == record.name + + +def select_records(native: list[CoverageRecord], lcov: list[CoverageRecord]) -> list[CoverageRecord]: + """Prefer typed native metrics and use LCOV only where native lacks a metric.""" + selected: list[CoverageRecord] = [] + for metric in sorted(METRICS): + native_metric = [record for record in native if record.metric == metric] + selected.extend(native_metric or [record for record in lcov if record.metric == metric]) + return selected + + +def qualify( + policy: dict[str, Any], + records: list[CoverageRecord], + formal_result: str, + formal_reached: int, + source_name: str, +) -> tuple[dict[str, Any], list[str]]: + """Apply exclusions, percentages, named points, and formal reachability.""" + failures: list[str] = [] + scoped = [record for record in records if source_selected(record.source, policy)] + exclusion_results: list[dict[str, Any]] = [] + excluded_indexes: set[int] = set() + for exclusion_index, exclusion in enumerate(policy["exclusions"]): + matches = [index for index, record in enumerate(scoped) if exclusion_matches(exclusion, record)] + if not matches: + failures.append( + f"exclusion[{exclusion_index}] is stale or unmatched: " + f"{exclusion['metric']} {exclusion['source']} {exclusion['scope']}" + ) + excluded_indexes.update(matches) + exclusion_results.append({**exclusion, "matched_records": len(matches)}) + + effective = [record for index, record in enumerate(scoped) if index not in excluded_indexes] + metric_results: dict[str, dict[str, Any]] = {} + for metric in sorted(METRICS): + metric_records = [record for record in effective if record.metric == metric] + hit = sum(record.hits > 0 for record in metric_records) + total = len(metric_records) + percent = 100.0 * hit / total if total else None + threshold = policy["thresholds"].get(metric) + passed = threshold is None or (percent is not None and percent >= threshold) + if threshold is not None and total == 0: + failures.append(f"{metric} threshold {threshold:g}% has no matching coverage counters") + elif threshold is not None and not passed: + failures.append(f"{metric} coverage {percent:.2f}% is below threshold {threshold:g}%") + metric_results[metric] = { + "total": total, + "hit": hit, + "excluded": sum( + record.metric == metric for index, record in enumerate(scoped) if index in excluded_indexes + ), + "percent": None if percent is None else round(percent, 4), + "threshold": threshold, + "passed": passed, + } + + coverpoint_results: list[dict[str, Any]] = [] + user_records = [record for record in effective if record.metric == "user"] + for requirement in policy["coverpoints"]: + matching = [record for record in user_records if record.name == requirement["name"]] + hits = sum(record.hits for record in matching) + passed = bool(matching) and hits >= requirement["minimum_hits"] + if not matching: + failures.append(f"required coverpoint {requirement['name']} is absent from native HDL coverage") + elif not passed: + failures.append( + f"coverpoint {requirement['name']} has {hits} hits, requires {requirement['minimum_hits']}" + ) + coverpoint_results.append({**requirement, "hits": hits, "records": len(matching), "passed": passed}) + + formal_required = policy["formal"]["required"] + if formal_required and formal_result != "PASS": + failures.append(f"formal cover reachability is {formal_result}, expected PASS for all cover statements") + elif formal_required and formal_reached < 1: + failures.append("formal cover configuration reached no cover statements") + formal = { + "required": formal_required, + "status": formal_result if formal_required else "SKIP", + "reached_statements": formal_reached if formal_required else 0, + } + status = "FAIL" if failures else "PASS" + summary = { + "schema": "mosaic-coverage-qualification-v1", + "status": status, + "source": source_name, + "metrics": metric_results, + "coverpoints": coverpoint_results, + "exclusions": exclusion_results, + "formal": formal, + "failures": failures, + } + return summary, failures + + +def write_summary(path: Path, summary: dict[str, Any]) -> None: + """Write deterministic, reviewable machine evidence.""" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def command_inspect(arguments: argparse.Namespace) -> int: + """Expose validated policy fields to the shell adapter.""" + policy = load_policy(arguments.policy.resolve(), arguments.module_root.resolve()) + if arguments.field == "formal.required": + print("true" if policy["formal"]["required"] else "false") + return 0 + + +def command_qualify(arguments: argparse.Namespace) -> int: + """Normalize evidence, evaluate policy, and always preserve a JSON result.""" + output = arguments.output.resolve() + try: + policy = load_policy(arguments.policy.resolve(), arguments.module_root.resolve()) + native = parse_native(arguments.native.resolve(), arguments.module_root.resolve()) + lcov = parse_lcov(arguments.lcov.resolve(), arguments.module_root.resolve()) + if not native and not lcov: + raise QualificationError("No native or LCOV coverage counters were found") + summary, failures = qualify( + policy, + select_records(native, lcov), + arguments.formal_result, + arguments.formal_reached, + arguments.source, + ) + except (OSError, UnicodeError, ValueError) as error: + failures = [str(error)] + summary = { + "schema": "mosaic-coverage-qualification-v1", + "status": "FAIL", + "source": arguments.source, + "metrics": {}, + "coverpoints": [], + "exclusions": [], + "formal": { + "required": None, + "status": arguments.formal_result, + "reached_statements": arguments.formal_reached, + }, + "failures": failures, + } + write_summary(output, summary) + for failure in failures: + print(f"Coverage qualification: {failure}", file=sys.stderr) + if failures: + return 1 + print(f"Coverage qualification passed using {arguments.source}") + return 0 + + +def command_failure(arguments: argparse.Namespace) -> int: + """Preserve a structured result for adapter failures before qualification.""" + summary = { + "schema": "mosaic-coverage-qualification-v1", + "status": "FAIL", + "source": arguments.source, + "metrics": {}, + "coverpoints": [], + "exclusions": [], + "formal": { + "required": None, + "status": arguments.formal_result, + "reached_statements": 0, + }, + "failures": [arguments.message], + } + write_summary(arguments.output.resolve(), summary) + return 0 + + +def parser() -> argparse.ArgumentParser: + """Build the command-line contract used by Make and unit fixtures.""" + root = argparse.ArgumentParser(description=__doc__) + subparsers = root.add_subparsers(dest="command", required=True) + + inspect = subparsers.add_parser("inspect", help="validate and read policy controls") + inspect.add_argument("--policy", type=Path, required=True) + inspect.add_argument("--module-root", type=Path, required=True) + inspect.add_argument("--field", choices=["formal.required"], required=True) + inspect.set_defaults(function=command_inspect) + + qualify_parser = subparsers.add_parser("qualify", help="qualify collected HDL coverage") + qualify_parser.add_argument("--policy", type=Path, required=True) + qualify_parser.add_argument("--module-root", type=Path, required=True) + qualify_parser.add_argument("--native", type=Path, required=True) + qualify_parser.add_argument("--lcov", type=Path, required=True) + qualify_parser.add_argument("--source", required=True) + qualify_parser.add_argument("--formal-result", choices=["PASS", "FAIL", "SKIP"], required=True) + qualify_parser.add_argument("--formal-reached", type=int, default=0) + qualify_parser.add_argument("--output", type=Path, required=True) + qualify_parser.set_defaults(function=command_qualify) + + failure = subparsers.add_parser("record-failure", help="record an early adapter failure") + failure.add_argument("--source", required=True) + failure.add_argument("--message", required=True) + failure.add_argument("--formal-result", choices=["PASS", "FAIL", "SKIP"], default="SKIP") + failure.add_argument("--output", type=Path, required=True) + failure.set_defaults(function=command_failure) + return root + + +def main() -> int: + """Run one selected coverage-policy operation.""" + arguments = parser().parse_args() + try: + return arguments.function(arguments) + except QualificationError as error: + print(f"Coverage qualification: {error}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/ci/module_manifest.py b/ci/module_manifest.py new file mode 100755 index 0000000..49b1478 --- /dev/null +++ b/ci/module_manifest.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +"""Validate and query a MOSAIC multi-module project manifest.""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path +from typing import Any + + +SCHEMA = "mosaic-modules-v1" +MODULE_NAME = re.compile(r"^[a-z][a-z0-9_]*$") + + +class ManifestError(ValueError): + """Report a user-correctable manifest or project-layout error.""" + + +def load_manifest(path: Path, module_root: Path) -> dict[str, Any]: + """Load and validate one manifest and its canonical module config files.""" + try: + manifest = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError as error: + raise ManifestError(f"Module manifest does not exist: {path}") from error + except json.JSONDecodeError as error: + raise ManifestError( + f"Invalid JSON in {path}:{error.lineno}:{error.colno}: {error.msg}" + ) from error + + if not isinstance(manifest, dict): + raise ManifestError("Module manifest root must be a JSON object") + if manifest.get("schema") != SCHEMA: + raise ManifestError(f"Module manifest schema must be {SCHEMA!r}") + + entries = manifest.get("include") + if not isinstance(entries, list) or not entries: + raise ManifestError("Module manifest 'include' must be a nonempty array") + + names: set[str] = set() + for index, entry in enumerate(entries): + if not isinstance(entry, dict): + raise ManifestError(f"Module entry {index} must be a JSON object") + name = entry.get("name") + if not isinstance(name, str) or not MODULE_NAME.fullmatch(name): + raise ManifestError( + f"Module entry {index} has invalid name {name!r}; expected " + "lowercase letters, digits, and underscores" + ) + if name in names: + raise ManifestError(f"Duplicate module name: {name}") + names.add(name) + + design_config = module_root / "config" / "modules" / f"{name}.mk" + flow_config = module_root / "config" / "modules" / f"{name}-flows.mk" + if not design_config.is_file(): + raise ManifestError( + f"Module {name} is missing design configuration: {design_config}" + ) + if not flow_config.is_file(): + raise ManifestError( + f"Module {name} is missing flow policy: {flow_config}" + ) + + return manifest + + +def parse_args() -> argparse.Namespace: + """Parse the command used by Make and CI integrations.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("command", choices=("validate", "list", "matrix")) + parser.add_argument("--manifest", required=True, type=Path) + parser.add_argument("--module-root", required=True, type=Path) + parser.add_argument("--selected", default="") + parser.add_argument("--quiet", action="store_true") + return parser.parse_args() + + +def main() -> int: + """Validate the manifest and render the requested deterministic view.""" + arguments = parse_args() + manifest_path = arguments.manifest.resolve() + module_root = arguments.module_root.resolve() + + try: + manifest = load_manifest(manifest_path, module_root) + entries = manifest["include"] + names = [entry["name"] for entry in entries] + if arguments.selected and arguments.selected not in names: + raise ManifestError( + f"Unknown MODULE {arguments.selected!r}; registered modules: " + f"{', '.join(names)}" + ) + except ManifestError as error: + print(f"module manifest error: {error}", file=sys.stderr) + return 2 + + if arguments.command == "list": + print("\n".join(names)) + elif arguments.command == "matrix": + print(json.dumps({"include": entries}, separators=(",", ":"), sort_keys=True)) + elif not arguments.quiet: + print(f"Validated {len(names)} modules from {manifest_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/ci/open_source_quality_gate.sh b/ci/open_source_quality_gate.sh index 8ef48a4..dd61ee7 100755 --- a/ci/open_source_quality_gate.sh +++ b/ci/open_source_quality_gate.sh @@ -5,7 +5,7 @@ flow_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # shellcheck source=flow_selection.sh source "${flow_root}/ci/flow_selection.sh" -required_flows=(verible_lint verible_format slang_elaboration verilator_lint yosys_synthesis symbiyosys_formal eqy_equivalence verilator_sim pyuvm_open_source) +required_flows=(verible_lint verible_format slang_elaboration verilator_lint yosys_synthesis symbiyosys_formal eqy_equivalence verilator_sim pyuvm_open_source coverage_qualification negative_qualification four_state_qualification static_intent) failed=0 for flow_name in "${required_flows[@]}"; do diff --git a/ci/openroad_evidence.py b/ci/openroad_evidence.py new file mode 100755 index 0000000..eddf019 --- /dev/null +++ b/ci/openroad_evidence.py @@ -0,0 +1,355 @@ +#!/usr/bin/env python3 +"""Validate module-owned OpenROAD deliverables and violation thresholds.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import sys +from pathlib import Path +from typing import Any + + +POLICY_SCHEMA = "mosaic-openroad-evidence-policy-v1" +RESULT_SCHEMA = "mosaic-openroad-evidence-result-v1" +NAME = re.compile(r"^[a-z][a-z0-9_]*$") +IMAGE_DIGEST = re.compile(r"^sha256:[0-9a-f]{64}$") +IMAGE_REFERENCE = re.compile(r"^\S+@sha256:[0-9a-f]{64}$") +DIRECTORIES = {"results", "reports", "logs", "objects"} + + +class EvidenceError(ValueError): + """Report invalid policy or missing physical evidence.""" + + +def sha256(path: Path) -> str: + """Hash one nonempty regular file.""" + digest = hashlib.sha256() + with path.open("rb") as source: + for chunk in iter(lambda: source.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def checked_file(path: Path, label: str) -> Path: + """Require a nonempty regular file and return its resolved path.""" + resolved = path.resolve() + if not resolved.is_file(): + raise EvidenceError(f"Missing {label}: {resolved}") + if resolved.stat().st_size == 0: + raise EvidenceError(f"Empty {label}: {resolved}") + return resolved + + +def relative_record(path: Path, module_root: Path) -> dict[str, Any]: + """Describe a file with a stable module-relative path when possible.""" + resolved = path.resolve() + try: + display_path = resolved.relative_to(module_root.resolve()).as_posix() + except ValueError: + display_path = str(resolved) + return { + "path": display_path, + "bytes": resolved.stat().st_size, + "sha256": sha256(resolved), + } + + +def policy_path( + root: Path, + entry: dict[str, Any], + platform: str, + design: str, + variant: str, +) -> Path: + """Resolve a policy path below its declared ORFS evidence directory.""" + relative = Path(entry["path"]) + if relative.is_absolute() or ".." in relative.parts: + raise EvidenceError(f"Policy path must be relative and contained: {relative}") + return root / entry["directory"] / platform / design / variant / relative + + +def validate_policy(policy: object) -> dict[str, Any]: + """Validate the policy subset used by the dependency-free implementation.""" + if not isinstance(policy, dict) or set(policy) != { + "schema", + "artifacts", + "metrics", + }: + raise EvidenceError("OpenROAD policy root has invalid fields") + if policy["schema"] != POLICY_SCHEMA: + raise EvidenceError(f"OpenROAD policy schema must be {POLICY_SCHEMA}") + + for section in ("artifacts", "metrics"): + if not isinstance(policy[section], list) or not policy[section]: + raise EvidenceError(f"OpenROAD policy {section} must be a nonempty array") + + names: set[str] = set() + for index, artifact in enumerate(policy["artifacts"]): + if not isinstance(artifact, dict) or set(artifact) != { + "name", + "directory", + "path", + }: + raise EvidenceError(f"Artifact {index} has invalid fields") + validate_entry(artifact, f"Artifact {index}") + if artifact["name"] in names: + raise EvidenceError(f"Duplicate evidence name: {artifact['name']}") + names.add(artifact["name"]) + + for index, metric in enumerate(policy["metrics"]): + if not isinstance(metric, dict): + raise EvidenceError(f"Metric {index} must be an object") + allowed = { + "name", + "directory", + "path", + "pattern", + "match", + "minimum", + "maximum", + } + if not {"name", "directory", "path", "pattern"}.issubset(metric) or not set( + metric + ).issubset(allowed): + raise EvidenceError(f"Metric {index} has invalid fields") + validate_entry(metric, f"Metric {index}") + if metric["name"] in names: + raise EvidenceError(f"Duplicate evidence name: {metric['name']}") + names.add(metric["name"]) + if "minimum" not in metric and "maximum" not in metric: + raise EvidenceError(f"Metric {metric['name']} has no threshold") + if metric.get("match", "only") not in {"only", "first", "last"}: + raise EvidenceError( + f"Metric {metric['name']} match must be only, first, or last" + ) + for threshold in ("minimum", "maximum"): + if threshold in metric and ( + isinstance(metric[threshold], bool) + or not isinstance(metric[threshold], int) + or metric[threshold] < 0 + ): + raise EvidenceError( + f"Metric {metric['name']} {threshold} must be a nonnegative integer" + ) + try: + expression = re.compile(metric["pattern"], re.MULTILINE) + except (TypeError, re.error) as error: + raise EvidenceError( + f"Metric {metric['name']} has an invalid pattern: {error}" + ) from error + if "value" not in expression.groupindex: + raise EvidenceError( + f"Metric {metric['name']} pattern must define named group 'value'" + ) + return policy + + +def validate_entry(entry: dict[str, Any], label: str) -> None: + """Validate fields shared by artifact and metric entries.""" + if not isinstance(entry.get("name"), str) or not NAME.fullmatch(entry["name"]): + raise EvidenceError(f"{label} has an invalid name") + if entry.get("directory") not in DIRECTORIES: + raise EvidenceError(f"{label} has an invalid directory") + path = entry.get("path") + if ( + not isinstance(path, str) + or not path + or Path(path).is_absolute() + or ".." in Path(path).parts + ): + raise EvidenceError(f"{label} has an invalid relative path") + + +def load_policy(path: Path) -> dict[str, Any]: + """Read and validate one JSON physical evidence policy.""" + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError) as error: + raise EvidenceError(f"Cannot read OpenROAD policy {path}: {error}") from error + return validate_policy(raw) + + +def qualify(args: argparse.Namespace) -> dict[str, Any]: + """Evaluate artifacts and metrics and return normalized evidence.""" + module_root = args.module_root.resolve() + evidence_root = args.evidence_root.resolve() + policy_file = checked_file(args.policy, "OpenROAD evidence policy") + design_config = checked_file(args.design_config, "OpenROAD design configuration") + constraint = checked_file(args.constraint, "OpenROAD constraint") + policy = load_policy(policy_file) + failures: list[str] = [] + artifacts: list[dict[str, Any]] = [] + metrics: list[dict[str, Any]] = [] + + for declaration in policy["artifacts"]: + path = policy_path( + evidence_root, + declaration, + args.platform, + args.design, + args.variant, + ) + try: + record = relative_record( + checked_file(path, f"required artifact {declaration['name']}"), + module_root, + ) + artifacts.append({"name": declaration["name"], **record}) + except EvidenceError as error: + failures.append(str(error)) + + source_records: dict[Path, dict[str, Any]] = {} + for declaration in policy["metrics"]: + path = policy_path( + evidence_root, + declaration, + args.platform, + args.design, + args.variant, + ) + value: int | None = None + passed = False + try: + source = checked_file(path, f"metric source {declaration['name']}") + text = source.read_text(encoding="utf-8", errors="replace") + expression = re.compile(declaration["pattern"], re.MULTILINE) + matches = list(expression.finditer(text)) + selection = declaration.get("match", "only") + if not matches or (selection == "only" and len(matches) != 1): + raise EvidenceError( + f"Metric {declaration['name']} expected " + f"{'one' if selection == 'only' else 'at least one'} match " + f"in {source}, " + f"found {len(matches)}" + ) + selected_match = matches[-1] if selection == "last" else matches[0] + raw_value = selected_match.group("value") + if not re.fullmatch(r"[0-9]+", raw_value): + raise EvidenceError( + f"Metric {declaration['name']} value is not a nonnegative integer: " + f"{raw_value!r}" + ) + value = int(raw_value) + passed = ( + ("minimum" not in declaration or value >= declaration["minimum"]) + and ("maximum" not in declaration or value <= declaration["maximum"]) + ) + if not passed: + bounds = [] + if "minimum" in declaration: + bounds.append(f"minimum {declaration['minimum']}") + if "maximum" in declaration: + bounds.append(f"maximum {declaration['maximum']}") + failures.append( + f"Metric {declaration['name']} is {value}, expected " + f"{' and '.join(bounds)}" + ) + source_records.setdefault(source, relative_record(source, module_root)) + except (EvidenceError, OSError) as error: + failures.append(str(error)) + + metric: dict[str, Any] = { + "name": declaration["name"], + "value": value, + "passed": passed, + "match": declaration.get("match", "only"), + "source": source_records.get(path.resolve(), {"path": str(path.resolve())}), + } + for threshold in ("minimum", "maximum"): + if threshold in declaration: + metric[threshold] = declaration[threshold] + metrics.append(metric) + + image: dict[str, str] | None = None + if args.execution_mode == "container": + if not IMAGE_REFERENCE.fullmatch(args.image_reference) or not IMAGE_DIGEST.fullmatch( + args.image_digest + ): + failures.append( + "Container evidence requires an immutable image reference and digest" + ) + elif not args.image_reference.endswith(f"@{args.image_digest}"): + failures.append( + "Container image reference digest does not match the recorded digest" + ) + image = { + "reference": args.image_reference, + "digest": args.image_digest, + "id": args.image_id, + } + + return { + "schema": RESULT_SCHEMA, + "status": "PASS" if not failures else "FAIL", + "design": { + "name": args.design, + "platform": args.platform, + "variant": args.variant, + }, + "execution": { + "mode": args.execution_mode, + "runtime": args.runtime, + "runtime_version": args.runtime_version, + "orfs_revision": args.orfs_revision, + "image": image, + }, + "inputs": { + "policy": relative_record(policy_file, module_root), + "design_config": relative_record(design_config, module_root), + "constraint": relative_record(constraint, module_root), + }, + "artifacts": artifacts, + "metrics": metrics, + "failures": failures, + } + + +def parser() -> argparse.ArgumentParser: + """Construct the command-line parser.""" + result = argparse.ArgumentParser(description=__doc__) + result.add_argument("--module-root", type=Path, required=True) + result.add_argument("--policy", type=Path, required=True) + result.add_argument("--evidence-root", type=Path, required=True) + result.add_argument("--design-config", type=Path, required=True) + result.add_argument("--constraint", type=Path, required=True) + result.add_argument("--output", type=Path, required=True) + result.add_argument( + "--execution-mode", choices=("local", "container"), required=True + ) + result.add_argument("--design", required=True) + result.add_argument("--platform", required=True) + result.add_argument("--variant", required=True) + result.add_argument("--runtime", default="") + result.add_argument("--runtime-version", default="") + result.add_argument("--orfs-revision", default="") + result.add_argument("--image-reference", default="") + result.add_argument("--image-digest", default="") + result.add_argument("--image-id", default="") + return result + + +def main() -> int: + """Run qualification and always retain a result for policy failures.""" + args = parser().parse_args() + args.output.parent.mkdir(parents=True, exist_ok=True) + try: + evidence = qualify(args) + except EvidenceError as error: + print(f"OpenROAD evidence error: {error}", file=sys.stderr) + return 2 + args.output.write_text( + json.dumps(evidence, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + if evidence["status"] != "PASS": + for failure in evidence["failures"]: + print(f"OpenROAD evidence failure: {failure}", file=sys.stderr) + return 1 + print(f"OpenROAD physical evidence passed: {args.output}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/ci/parameter_profiles.py b/ci/parameter_profiles.py new file mode 100755 index 0000000..fcf4722 --- /dev/null +++ b/ci/parameter_profiles.py @@ -0,0 +1,445 @@ +#!/usr/bin/env python3 +"""Validate, query, and render MOSAIC parameter-profile manifests.""" + +from __future__ import annotations + +import argparse +import json +import re +import shlex +import sys +from pathlib import Path +from typing import Any + + +SCHEMA = "mosaic-parameter-profiles-v1" +PROFILE_NAME = re.compile(r"^[a-z][a-z0-9_]*$") +IDENTIFIER = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") +PARAMETER_VALUE = re.compile(r"^[A-Za-z0-9_.:'()+*/%<>=!?&|~^{},\[\]-]+$") +TOP_FIELDS = {"design", "testbench", "formal", "pyuvm"} +SUPPORTED_FLOWS = { + "verible_lint", + "verible_format", + "slang_elaboration", + "verilator_lint", + "yosys_synthesis", + "symbiyosys_formal", + "eqy_equivalence", + "verilator_sim", + "pyuvm_open_source", + "negative_qualification", + "four_state_qualification", + "static_intent", + "vcs_sim", + "pyuvm_commercial", + "synopsys_synthesis", + "synopsys_primetime", + "synopsys_primepower", +} +FLOW_DEPENDENCIES = { + "eqy_equivalence": {"yosys_synthesis"}, + "synopsys_primetime": {"synopsys_synthesis"}, + "synopsys_primepower": {"vcs_sim", "synopsys_synthesis"}, +} + + +class ProfileError(ValueError): + """Report a user-correctable profile declaration error.""" + + +def normalized_value(value: object) -> str: + """Convert one JSON scalar into a backend-neutral HDL constant token.""" + if isinstance(value, bool): + return "1" if value else "0" + if isinstance(value, int): + return str(value) + if isinstance(value, str) and value and PARAMETER_VALUE.fullmatch(value): + return value + raise ProfileError( + f"unsupported parameter value {value!r}; use an integer, boolean, or " + "whitespace-free HDL constant without semicolons" + ) + + +def validate_profile(entry: object, index: int, names: set[str]) -> dict[str, Any]: + """Validate one profile object and return it with a narrowed type.""" + if not isinstance(entry, dict): + raise ProfileError(f"Profile entry {index} must be a JSON object") + + name = entry.get("name") + if not isinstance(name, str) or not PROFILE_NAME.fullmatch(name): + raise ProfileError( + f"Profile entry {index} has invalid name {name!r}; expected lowercase " + "letters, digits, and underscores" + ) + if name in names: + raise ProfileError(f"Duplicate profile name: {name}") + names.add(name) + + parameters = entry.get("parameters") + if not isinstance(parameters, dict): + raise ProfileError(f"Profile {name} must declare a parameters object") + for parameter_name, value in parameters.items(): + if not isinstance(parameter_name, str) or not IDENTIFIER.fullmatch( + parameter_name + ): + raise ProfileError( + f"Profile {name} has invalid parameter name: {parameter_name!r}" + ) + try: + normalized_value(value) + except ProfileError as error: + raise ProfileError(f"Profile {name} has {error}") from error + + flows = entry.get("flows") + if not isinstance(flows, list) or not flows: + raise ProfileError(f"Profile {name} must declare a nonempty flows array") + if any(not isinstance(flow, str) for flow in flows): + raise ProfileError(f"Profile {name} flows must be strings") + duplicate_flows = sorted({flow for flow in flows if flows.count(flow) > 1}) + if duplicate_flows: + raise ProfileError( + f"Profile {name} repeats flows: {', '.join(duplicate_flows)}" + ) + unsupported = sorted(set(flows) - SUPPORTED_FLOWS) + if unsupported: + raise ProfileError( + f"Profile {name} selects unsupported flows: {', '.join(unsupported)}" + ) + for flow, dependencies in FLOW_DEPENDENCIES.items(): + if flow in flows and not dependencies.issubset(flows): + missing = ", ".join(sorted(dependencies - set(flows))) + raise ProfileError(f"Profile {name} flow {flow} requires: {missing}") + + tops = entry.get("tops", {}) + if not isinstance(tops, dict): + raise ProfileError(f"Profile {name} tops must be a JSON object") + unsupported_tops = sorted(set(tops) - TOP_FIELDS) + if unsupported_tops: + raise ProfileError( + f"Profile {name} has unsupported top fields: {', '.join(unsupported_tops)}" + ) + for top_kind, top_name in tops.items(): + if not isinstance(top_name, str) or not IDENTIFIER.fullmatch(top_name): + raise ProfileError( + f"Profile {name} has invalid {top_kind} top: {top_name!r}" + ) + + unknown_fields = sorted(set(entry) - {"name", "parameters", "flows", "tops"}) + if unknown_fields: + raise ProfileError( + f"Profile {name} has unsupported fields: {', '.join(unknown_fields)}" + ) + return entry + + +def load_manifest(path: Path) -> dict[str, Any]: + """Load and validate a parameter-profile manifest.""" + try: + manifest = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError as error: + raise ProfileError(f"Parameter-profile manifest does not exist: {path}") from error + except json.JSONDecodeError as error: + raise ProfileError( + f"Invalid JSON in {path}:{error.lineno}:{error.colno}: {error.msg}" + ) from error + + if not isinstance(manifest, dict): + raise ProfileError("Parameter-profile manifest root must be a JSON object") + if manifest.get("schema") != SCHEMA: + raise ProfileError(f"Parameter-profile manifest schema must be {SCHEMA!r}") + if set(manifest) != {"schema", "include"}: + unsupported = sorted(set(manifest) - {"schema", "include"}) + missing = sorted({"schema", "include"} - set(manifest)) + details = unsupported or missing + raise ProfileError( + "Parameter-profile manifest has invalid root fields: " + ", ".join(details) + ) + + entries = manifest.get("include") + if not isinstance(entries, list) or not entries: + raise ProfileError("Parameter-profile manifest 'include' must be nonempty") + names: set[str] = set() + for index, entry in enumerate(entries): + validate_profile(entry, index, names) + return manifest + + +def selected_profile(manifest: dict[str, Any], selected: str) -> dict[str, Any]: + """Return one named profile or report the registered choices.""" + for entry in manifest["include"]: + if entry["name"] == selected: + return entry + names = ", ".join(entry["name"] for entry in manifest["include"]) + raise ProfileError(f"Unknown PROFILE {selected!r}; registered profiles: {names}") + + +def matrix_entry(entry: dict[str, Any], module: str) -> dict[str, Any]: + """Create one collision-resistant GitHub Actions matrix entry.""" + profile = entry["name"] + result = {key: value for key, value in entry.items() if key != "name"} + result["profile"] = profile + if module: + result["module"] = module + result["job_name"] = f"{module or 'module'}--{profile}" + return result + + +def parameter_arguments(parameters: dict[str, object], backend: str, top: str) -> list[str]: + """Translate the canonical map into one backend's command-line arguments.""" + pairs = [(name, normalized_value(value)) for name, value in parameters.items()] + if backend == "slang": + return [item for name, value in pairs for item in ("-G", f"{name}={value}")] + if backend == "verilator": + return [f"-G{name}={value}" for name, value in pairs] + if backend == "vcs": + return [f"-pvalue+{top}.{name}={value}" for name, value in pairs] + if backend == "dc": + return [",".join(f"{name}={value}" for name, value in pairs)] if pairs else [] + if backend == "yosys": + return [f"chparam -set {name} {value} {top};" for name, value in pairs] + raise ProfileError(f"Unsupported parameter backend: {backend}") + + +def rewrite_prep_top(line: str, top: str) -> str: + """Replace a Yosys prep command's top while retaining other options.""" + tokens = shlex.split(line) + if not tokens or tokens[0] != "prep": + return line + if "-top" in tokens: + tokens[tokens.index("-top") + 1] = top + else: + tokens.extend(["-top", top]) + return shlex.join(tokens) + + +def render_sby(source: Path, destination: Path, top: str, parameters: dict[str, object]) -> None: + """Inject formal-top parameters before prep in an SBY script section.""" + lines = source.read_text(encoding="utf-8").splitlines() + rendered: list[str] = [] + section = "" + injected = False + for line in lines: + if line.startswith("[") and line.endswith("]"): + section = line[1:-1] + if section == "script" and shlex.split(line)[:1] == ["prep"]: + for command in parameter_arguments(parameters, "yosys", top): + rendered.append(command.removesuffix(";")) + line = rewrite_prep_top(line, top) + injected = True + rendered.append(line) + if not injected: + raise ProfileError(f"SBY configuration has no prep command in [script]: {source}") + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text("\n".join(rendered) + "\n", encoding="utf-8") + + +def render_eqy( + source: Path, + destination: Path, + top: str, + netlist: Path, + parameters: dict[str, object], +) -> None: + """Bind an EQY configuration to matching parameterized gold and gate inputs.""" + lines = source.read_text(encoding="utf-8").splitlines() + rendered: list[str] = [] + section = "" + injected_gold = False + replaced_gate_inputs = 0 + for line in lines: + if line.startswith("[") and line.endswith("]"): + section = line[1:-1] + tokens = shlex.split(line) + if section == "gold" and tokens[:1] == ["prep"]: + for command in parameter_arguments(parameters, "yosys", top): + rendered.append(command.removesuffix(";")) + line = rewrite_prep_top(line, top) + injected_gold = True + elif section == "gate" and tokens[:1] == ["prep"]: + line = rewrite_prep_top(line, top) + elif section == "gate" and tokens[:1] in (["read"], ["read_verilog"]): + for index, token in enumerate(tokens): + if token.endswith((".v", ".sv")): + tokens[index] = str(netlist) + replaced_gate_inputs += 1 + line = shlex.join(tokens) + rendered.append(line) + if not injected_gold: + raise ProfileError(f"EQY configuration has no prep command in [gold]: {source}") + if replaced_gate_inputs != 1: + raise ProfileError( + "EQY configuration must have exactly one gate HDL input, found " + f"{replaced_gate_inputs}: {source}" + ) + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text("\n".join(rendered) + "\n", encoding="utf-8") + + +def parser() -> argparse.ArgumentParser: + """Build the command-line interface shared by Make and flow adapters.""" + result = argparse.ArgumentParser(description=__doc__) + subparsers = result.add_subparsers(dest="command", required=True) + + for command in ("validate", "list", "matrix", "get", "evidence", "summary"): + subparser = subparsers.add_parser(command) + subparser.add_argument("--manifest", required=True, type=Path) + if command in {"get", "evidence"}: + subparser.add_argument("--selected", required=True) + if command == "validate": + subparser.add_argument("--quiet", action="store_true") + if command == "matrix": + subparser.add_argument("--module", default="") + if command == "get": + subparser.add_argument( + "--field", + required=True, + choices=("parameters", "flows", "design", "testbench", "formal", "pyuvm"), + ) + if command == "evidence": + subparser.add_argument("--output", required=True, type=Path) + for top in sorted(TOP_FIELDS): + subparser.add_argument(f"--{top}-top", required=True) + if command == "summary": + subparser.add_argument("--report-root", required=True, type=Path) + subparser.add_argument("--output", required=True, type=Path) + + arguments = subparsers.add_parser("arguments") + arguments.add_argument("--parameters", required=True) + arguments.add_argument( + "--backend", required=True, choices=("slang", "verilator", "vcs", "dc", "yosys") + ) + arguments.add_argument("--top", required=True) + + for command in ("render-sby", "render-eqy"): + subparser = subparsers.add_parser(command) + subparser.add_argument("--input", required=True, type=Path) + subparser.add_argument("--output", required=True, type=Path) + subparser.add_argument("--parameters", required=True) + subparser.add_argument("--top", required=True) + if command == "render-eqy": + subparser.add_argument("--netlist", required=True, type=Path) + + subparsers.add_parser("combine-matrices") + return result + + +def main() -> int: + """Execute one validated manifest or adapter operation.""" + arguments = parser().parse_args() + try: + if arguments.command == "combine-matrices": + entries: list[dict[str, Any]] = [] + job_names: set[str] = set() + for line in sys.stdin: + if not line.strip(): + continue + matrix = json.loads(line) + for entry in matrix["include"]: + if entry["job_name"] in job_names: + raise ProfileError(f"Duplicate matrix job_name: {entry['job_name']}") + job_names.add(entry["job_name"]) + entries.append(entry) + print(json.dumps({"include": entries}, separators=(",", ":"), sort_keys=True)) + return 0 + + if arguments.command in {"arguments", "render-sby", "render-eqy"}: + parameters = json.loads(arguments.parameters) + if not isinstance(parameters, dict): + raise ProfileError("Parameters must decode to a JSON object") + for name, value in parameters.items(): + if not IDENTIFIER.fullmatch(name): + raise ProfileError(f"Invalid parameter name: {name!r}") + normalized_value(value) + if arguments.command == "arguments": + print("\n".join(parameter_arguments(parameters, arguments.backend, arguments.top))) + elif arguments.command == "render-sby": + render_sby(arguments.input, arguments.output, arguments.top, parameters) + else: + render_eqy( + arguments.input, + arguments.output, + arguments.top, + arguments.netlist, + parameters, + ) + return 0 + + manifest_path = arguments.manifest.resolve() + manifest = load_manifest(manifest_path) + entries = manifest["include"] + if arguments.command == "validate": + if not arguments.quiet: + print(f"Validated {len(entries)} profiles from {manifest_path}") + elif arguments.command == "list": + print("\n".join(entry["name"] for entry in entries)) + elif arguments.command == "matrix": + matrix = [matrix_entry(entry, arguments.module) for entry in entries] + print(json.dumps({"include": matrix}, separators=(",", ":"), sort_keys=True)) + elif arguments.command == "summary": + profiles = [] + aggregate_status = "PASS" + for entry in entries: + profile_root = arguments.report_root / entry["name"] + statuses = { + str(path.parent.relative_to(profile_root)): path.read_text( + encoding="utf-8" + ).strip() + for path in sorted(profile_root.rglob("status.txt")) + } + if not statuses: + statuses = {"aggregate": "MISSING"} + if any(status not in {"PASS", "SKIP"} for status in statuses.values()): + aggregate_status = "FAIL" + profiles.append( + { + "profile": entry["name"], + "parameters": entry["parameters"], + "statuses": statuses, + } + ) + summary = { + "schema": SCHEMA, + "aggregate_status": aggregate_status, + "profiles": profiles, + } + arguments.output.parent.mkdir(parents=True, exist_ok=True) + arguments.output.write_text( + json.dumps(summary, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + if aggregate_status != "PASS": + return 1 + else: + entry = selected_profile(manifest, arguments.selected) + if arguments.command == "get": + if arguments.field == "parameters": + print(json.dumps(entry["parameters"], separators=(",", ":"), sort_keys=True)) + elif arguments.field == "flows": + print(" ".join(entry["flows"])) + else: + print(entry.get("tops", {}).get(arguments.field, "")) + else: + evidence = { + "schema": SCHEMA, + "profile": entry["name"], + "parameters": entry["parameters"], + "flows": entry["flows"], + "tops": { + "design": arguments.design_top, + "testbench": arguments.testbench_top, + "formal": arguments.formal_top, + "pyuvm": arguments.pyuvm_top, + }, + } + arguments.output.parent.mkdir(parents=True, exist_ok=True) + arguments.output.write_text( + json.dumps(evidence, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + except (ProfileError, json.JSONDecodeError, KeyError, IndexError) as error: + print(f"parameter profile error: {error}", file=sys.stderr) + return 2 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/ci/qualification_campaign.py b/ci/qualification_campaign.py new file mode 100755 index 0000000..378ec0c --- /dev/null +++ b/ci/qualification_campaign.py @@ -0,0 +1,560 @@ +#!/usr/bin/env python3 +"""Run declarative negative-test and four-state qualification campaigns.""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import shutil +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + + +SCHEMA = "mosaic-qualification-campaigns-v1" +SUMMARY_SCHEMA = "mosaic-qualification-result-v1" +NAME = re.compile(r"^[a-z][a-z0-9_]*$") +CAMPAIGNS = {"negative", "four_state"} +ROLES = { + "negative": {"negative", "positive_control"}, + "four_state": {"unknown_detection", "disabled_monitor_control"}, +} +PHASES = {"compile", "run"} +FAILURE_CLASSES = { + "assertion", + "elaboration", + "equivalence", + "mutation", + "unknown_control", +} +DEFAULT_INFRASTRUCTURE_DIAGNOSTICS = [ + r"command not found", + r"No such file or directory", + r"license checkout failed", + r"internal (?:compiler|tool) error", + r"INFRASTRUCTURE_FAILURE", +] + + +class CampaignError(ValueError): + """Report an invalid campaign or execution contract.""" + + +def write_json(path: Path, value: object) -> None: + """Write stable, human-readable JSON evidence.""" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def write_status(path: Path, status: str) -> None: + """Write one status using the shared flow evidence contract.""" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(f"{status}\n", encoding="utf-8") + + +def require_fields(value: dict[str, Any], allowed: set[str], required: set[str], label: str) -> None: + """Reject missing and unknown manifest fields.""" + missing = sorted(required - set(value)) + unknown = sorted(set(value) - allowed) + if missing: + raise CampaignError(f"{label} is missing fields: {', '.join(missing)}") + if unknown: + raise CampaignError(f"{label} has unknown fields: {', '.join(unknown)}") + + +def validate_pattern(pattern: object, label: str) -> str: + """Validate and compile one diagnostic regular expression.""" + if not isinstance(pattern, str) or not pattern: + raise CampaignError(f"{label} must be a nonempty regular expression") + try: + re.compile(pattern) + except re.error as error: + raise CampaignError(f"{label} is invalid: {error}") from error + return pattern + + +def validate_phase(phase: object, label: str) -> dict[str, Any]: + """Validate one compile or run phase.""" + if not isinstance(phase, dict): + raise CampaignError(f"{label} must be an object") + require_fields( + phase, + { + "name", + "command", + "fixture", + "expected", + "diagnostic", + "failure_class", + "environment", + "timeout_seconds", + }, + {"name", "expected"}, + label, + ) + if phase["name"] not in PHASES: + raise CampaignError(f"{label}.name must be compile or run") + if ("command" in phase) == ("fixture" in phase): + raise CampaignError(f"{label} must define exactly one of command or fixture") + if "command" in phase and ( + not isinstance(phase["command"], list) + or not phase["command"] + or not all(isinstance(item, str) and item for item in phase["command"]) + ): + raise CampaignError(f"{label}.command must be a nonempty string array") + if "fixture" in phase and ( + not isinstance(phase["fixture"], str) or not phase["fixture"] + ): + raise CampaignError(f"{label}.fixture must be a nonempty path") + if phase["expected"] not in {"success", "failure"}: + raise CampaignError(f"{label}.expected must be success or failure") + if phase["expected"] == "failure": + validate_pattern(phase.get("diagnostic"), f"{label}.diagnostic") + if phase.get("failure_class") not in FAILURE_CLASSES: + raise CampaignError( + f"{label}.failure_class must identify an expected design-check failure" + ) + elif "failure_class" in phase: + raise CampaignError(f"{label}.failure_class is only valid for expected failure") + if "diagnostic" in phase: + validate_pattern(phase["diagnostic"], f"{label}.diagnostic") + environment = phase.get("environment", {}) + if not isinstance(environment, dict) or not all( + isinstance(key, str) + and key + and isinstance(value, str) + for key, value in environment.items() + ): + raise CampaignError(f"{label}.environment must map names to strings") + timeout = phase.get("timeout_seconds", 300) + if not isinstance(timeout, int) or isinstance(timeout, bool) or timeout < 1: + raise CampaignError(f"{label}.timeout_seconds must be a positive integer") + return phase + + +def validate_case(case: object, campaign: str, label: str) -> dict[str, Any]: + """Validate one campaign case and its semantic role.""" + if not isinstance(case, dict): + raise CampaignError(f"{label} must be an object") + allowed = { + "id", + "evidence", + "role", + "kind", + "phases", + "positive_control", + "monitor_control", + "injections", + } + require_fields(case, allowed, {"id", "evidence", "role", "kind", "phases"}, label) + for field in ("id", "evidence"): + if not isinstance(case[field], str) or not NAME.fullmatch(case[field]): + raise CampaignError(f"{label}.{field} must match {NAME.pattern}") + if case["role"] not in ROLES[campaign]: + raise CampaignError(f"{label}.role is invalid for {campaign}") + if not isinstance(case["kind"], str) or not case["kind"]: + raise CampaignError(f"{label}.kind must be a nonempty string") + phases = case["phases"] + if not isinstance(phases, list) or not phases: + raise CampaignError(f"{label}.phases must be a nonempty array") + validated_phases = [ + validate_phase(phase, f"{label}.phases[{index}]") + for index, phase in enumerate(phases) + ] + phase_names = [phase["name"] for phase in validated_phases] + if len(phase_names) != len(set(phase_names)): + raise CampaignError(f"{label} repeats a phase name") + + expected_failures = sum(phase["expected"] == "failure" for phase in validated_phases) + if case["role"] in {"positive_control", "disabled_monitor_control"}: + if expected_failures: + raise CampaignError(f"{label} control phases must all expect success") + run_phases = [phase for phase in validated_phases if phase["name"] == "run"] + if campaign == "four_state" and ( + not run_phases or not run_phases[0].get("diagnostic") + ): + raise CampaignError( + f"{label} disabled-monitor control must prove stimulus arrival with a diagnostic" + ) + elif expected_failures != 1: + raise CampaignError(f"{label} must contain exactly one expected failure") + + if campaign == "negative" and case["role"] == "negative": + control = case.get("positive_control") + if not isinstance(control, str) or not NAME.fullmatch(control): + raise CampaignError(f"{label}.positive_control is required") + if campaign == "four_state": + injections = case.get("injections") + if ( + not isinstance(injections, list) + or not injections + or not all( + isinstance(item, dict) + and set(item) == {"control", "values"} + and isinstance(item["control"], str) + and item["control"] + and isinstance(item["values"], list) + and item["values"] + and all(value in {"X", "Z"} for value in item["values"]) + for item in injections + ) + ): + raise CampaignError( + f"{label}.injections must declare control names and X/Z values" + ) + by_phase = {phase["name"]: phase for phase in validated_phases} + if set(by_phase) != PHASES: + raise CampaignError(f"{label} must define compile and run phases") + if by_phase["compile"].get("command", [None])[0] != "{iverilog}": + raise CampaignError(f"{label} compile phase must invoke {{iverilog}} directly") + if by_phase["run"].get("command", [None])[0] != "{vvp}": + raise CampaignError(f"{label} run phase must invoke {{vvp}} directly") + if case["role"] == "unknown_detection": + control = case.get("monitor_control") + if not isinstance(control, str) or not NAME.fullmatch(control): + raise CampaignError(f"{label}.monitor_control is required") + return case + + +def load_campaign(path: Path, campaign: str) -> tuple[dict[str, Any], list[str]]: + """Load and semantically validate one selected campaign.""" + try: + manifest = json.loads(path.read_text(encoding="utf-8")) + except OSError as error: + raise CampaignError(f"Cannot read campaign manifest {path}: {error}") from error + except json.JSONDecodeError as error: + raise CampaignError( + f"Campaign manifest JSON is invalid at column {error.colno}: {error.msg}" + ) from error + if not isinstance(manifest, dict): + raise CampaignError("Campaign manifest root must be an object") + require_fields(manifest, {"schema", "campaigns"}, {"schema", "campaigns"}, "manifest") + if manifest["schema"] != SCHEMA: + raise CampaignError(f"Campaign manifest schema must be {SCHEMA}") + campaigns = manifest["campaigns"] + if not isinstance(campaigns, dict) or not set(campaigns).issubset(CAMPAIGNS): + raise CampaignError("manifest.campaigns contains an unknown campaign") + if campaign not in campaigns: + raise CampaignError(f"Campaign manifest does not define {campaign}") + + selected = campaigns[campaign] + if not isinstance(selected, dict): + raise CampaignError(f"campaigns.{campaign} must be an object") + allowed = {"cases", "infrastructure_diagnostics", "inputs"} + if campaign == "four_state": + allowed.add("simulator") + require_fields(selected, allowed, {"cases"}, f"campaigns.{campaign}") + if campaign == "four_state" and selected.get("simulator") != "iverilog": + raise CampaignError("four_state.simulator must be pinned to iverilog") + patterns = selected.get("infrastructure_diagnostics", []) + if not isinstance(patterns, list): + raise CampaignError("infrastructure_diagnostics must be an array") + infrastructure = [ + *DEFAULT_INFRASTRUCTURE_DIAGNOSTICS, + *[ + validate_pattern(pattern, f"infrastructure_diagnostics[{index}]") + for index, pattern in enumerate(patterns) + ], + ] + inputs = selected.get("inputs", []) + if ( + not isinstance(inputs, list) + or not all(isinstance(item, str) and item for item in inputs) + or len(inputs) != len(set(inputs)) + ): + raise CampaignError(f"campaigns.{campaign}.inputs must be a unique string array") + cases = selected["cases"] + if not isinstance(cases, list) or not cases: + raise CampaignError(f"campaigns.{campaign}.cases must be a nonempty array") + validated = [ + validate_case(case, campaign, f"campaigns.{campaign}.cases[{index}]") + for index, case in enumerate(cases) + ] + missing_roles = ROLES[campaign] - {case["role"] for case in validated} + if missing_roles: + raise CampaignError( + f"campaigns.{campaign} is missing required roles: {', '.join(sorted(missing_roles))}" + ) + ids = [case["id"] for case in validated] + evidence = [case["evidence"] for case in validated] + if len(ids) != len(set(ids)): + raise CampaignError(f"campaigns.{campaign} repeats a case ID") + if len(evidence) != len(set(evidence)): + raise CampaignError(f"campaigns.{campaign} repeats an evidence name") + by_id = {case["id"]: case for case in validated} + for case in validated: + reference = case.get("positive_control") or case.get("monitor_control") + if reference: + expected_role = ( + "positive_control" if campaign == "negative" else "disabled_monitor_control" + ) + if reference not in by_id or by_id[reference]["role"] != expected_role: + raise CampaignError( + f"case {case['id']} references missing {expected_role} {reference}" + ) + if campaign == "four_state" and case["injections"] != by_id[reference]["injections"]: + raise CampaignError( + f"case {case['id']} and monitor control {reference} must declare identical injections" + ) + selected["cases"] = validated + return selected, infrastructure + + +def expand(value: str, variables: dict[str, str], label: str) -> str: + """Expand only the documented brace placeholders in a manifest string.""" + try: + return value.format_map(variables) + except KeyError as error: + raise CampaignError(f"{label} uses unknown placeholder {{{error.args[0]}}}") from error + except ValueError as error: + raise CampaignError(f"{label} has invalid placeholder syntax: {error}") from error + + +def classify_phase( + phase: dict[str, Any], returncode: int, output: str, infrastructure: list[str] +) -> tuple[bool, str, str]: + """Distinguish expected design-check outcomes from escaped and tool failures.""" + expected = phase["expected"] + diagnostic = phase.get("diagnostic") + diagnostic_seen = bool(diagnostic and re.search(diagnostic, output, re.MULTILINE)) + infrastructure_seen = any( + re.search(pattern, output, re.IGNORECASE | re.MULTILINE) + for pattern in infrastructure + ) + if expected == "success": + if returncode != 0: + classification = ( + "infrastructure_failure" if infrastructure_seen else "unexpected_failure" + ) + return False, classification, "phase returned nonzero but success was required" + if diagnostic and not diagnostic_seen: + return False, "missing_diagnostic", "required success diagnostic was absent" + return True, "expected_success", "phase succeeded" + if returncode == 0: + return False, "escaped_fault", "design-check failure was expected but command succeeded" + if infrastructure_seen: + return False, "infrastructure_failure", "tool or execution infrastructure failed" + if not diagnostic_seen: + return False, "unexpected_failure", "nonzero result lacked the required diagnostic" + return True, f"expected_{phase['failure_class']}_failure", "expected failure was detected" + + +def run_phase( + phase: dict[str, Any], variables: dict[str, str], infrastructure: list[str], case_dir: Path +) -> dict[str, Any]: + """Execute one phase and emit its complete command and log evidence.""" + phase_name = phase["name"] + if "command" in phase: + command = [ + expand(argument, variables, f"{phase_name}.command") + for argument in phase["command"] + ] + else: + command = [expand(phase["fixture"], variables, f"{phase_name}.fixture")] + environment = os.environ.copy() + environment.update( + { + key: expand(value, variables, f"{phase_name}.environment.{key}") + for key, value in phase.get("environment", {}).items() + } + ) + command_record = { + "argv": command, + "environment": phase.get("environment", {}), + "timeout_seconds": phase.get("timeout_seconds", 300), + } + write_json(case_dir / f"{phase_name}-command.json", command_record) + started = datetime.now(timezone.utc) + try: + completed = subprocess.run( + command, + cwd=variables["module_root"], + env=environment, + capture_output=True, + text=True, + errors="replace", + timeout=phase.get("timeout_seconds", 300), + check=False, + ) + stdout = completed.stdout + stderr = completed.stderr + returncode = completed.returncode + passed, classification, message = classify_phase( + phase, returncode, stdout + stderr, infrastructure + ) + except (OSError, subprocess.TimeoutExpired) as error: + stdout = getattr(error, "stdout", "") or "" + stderr = (getattr(error, "stderr", "") or "") + f"\n{error}\n" + returncode = None + passed = False + classification = "infrastructure_failure" + message = "command could not be executed" + (case_dir / f"{phase_name}.log").write_text(stdout + stderr, encoding="utf-8") + result = { + "classification": classification, + "duration_seconds": round((datetime.now(timezone.utc) - started).total_seconds(), 6), + "expected": phase["expected"], + "message": message, + "name": phase_name, + "returncode": returncode, + "status": "PASS" if passed else "FAIL", + } + write_json(case_dir / f"{phase_name}-result.json", result) + return result + + +def run_case( + case: dict[str, Any], module_root: Path, flow_root: Path, report_dir: Path, + work_dir: Path, infrastructure: list[str], controls: dict[str, str] +) -> dict[str, Any]: + """Run one isolated case after checking its declared control dependency.""" + case_report_dir = report_dir / case["evidence"] + case_work_dir = work_dir / case["evidence"] + case_report_dir.mkdir(parents=True, exist_ok=True) + case_work_dir.mkdir(parents=True, exist_ok=True) + write_status(case_report_dir / "status.txt", "FAIL") + reference = case.get("positive_control") or case.get("monitor_control") + if reference and controls.get(reference) != "PASS": + result = { + "classification": "blocked_by_control", + "evidence": case["evidence"], + "id": case["id"], + "kind": case["kind"], + "message": f"required control {reference} did not pass", + "phases": [], + "role": case["role"], + "status": "FAIL", + } + write_json(case_report_dir / "summary.json", result) + return result + variables = { + "case_report_dir": str(case_report_dir), + "case_work_dir": str(case_work_dir), + "eqy": os.environ.get("EQY_CMD", "eqy"), + "flow_root": str(flow_root), + "iverilog": os.environ.get("IVERILOG_CMD", "iverilog"), + "module_root": str(module_root), + "python": sys.executable, + "report_dir": str(report_dir), + "verilator": os.environ.get("VERILATOR_CMD", "verilator"), + "vvp": os.environ.get("VVP_CMD", "vvp"), + "work_dir": str(work_dir), + } + phase_results = [] + for phase in case["phases"]: + result = run_phase(phase, variables, infrastructure, case_report_dir) + phase_results.append(result) + if result["status"] != "PASS": + break + passed = len(phase_results) == len(case["phases"]) and all( + result["status"] == "PASS" for result in phase_results + ) + classification = ( + phase_results[-1]["classification"] if phase_results else "not_run" + ) + result = { + "classification": classification, + "evidence": case["evidence"], + "id": case["id"], + "kind": case["kind"], + "phases": phase_results, + "role": case["role"], + "status": "PASS" if passed else "FAIL", + } + if "injections" in case: + result["injections"] = case["injections"] + write_json(case_report_dir / "summary.json", result) + write_status(case_report_dir / "status.txt", result["status"]) + return result + + +def execute(args: argparse.Namespace) -> int: + """Validate and execute one campaign, preserving evidence on every failure.""" + module_root = args.module_root.resolve() + flow_root = args.flow_root.resolve() + report_dir = args.report_dir.resolve() + work_dir = args.work_dir.resolve() + shutil.rmtree(report_dir, ignore_errors=True) + shutil.rmtree(work_dir, ignore_errors=True) + report_dir.mkdir(parents=True) + work_dir.mkdir(parents=True) + write_status(report_dir / "status.txt", "FAIL") + try: + selected, infrastructure = load_campaign(args.manifest.resolve(), args.campaign) + for declared_input in selected.get("inputs", []): + input_path = (module_root / declared_input).resolve() + try: + input_path.relative_to(module_root) + except ValueError as error: + raise CampaignError( + f"campaign input must be inside module root: {declared_input}" + ) from error + if not input_path.exists(): + raise CampaignError(f"campaign input is missing: {declared_input}") + controls: dict[str, str] = {} + cases = sorted( + selected["cases"], + key=lambda case: case["role"] not in {"positive_control", "disabled_monitor_control"}, + ) + results = [] + case_report_root = report_dir / selected.get("simulator", "") + case_work_root = work_dir / selected.get("simulator", "") + for case in cases: + result = run_case( + case, + module_root, + flow_root, + case_report_root, + case_work_root, + infrastructure, + controls, + ) + results.append(result) + if case["role"] in {"positive_control", "disabled_monitor_control"}: + controls[case["id"]] = result["status"] + passed = all(result["status"] == "PASS" for result in results) + summary = { + "campaign": args.campaign, + "cases": results, + "manifest": str(args.manifest.resolve().relative_to(module_root)), + "inputs": selected.get("inputs", []), + "schema": SUMMARY_SCHEMA, + "simulator": selected.get("simulator"), + "status": "PASS" if passed else "FAIL", + } + except (CampaignError, ValueError) as error: + summary = { + "campaign": args.campaign, + "cases": [], + "errors": [str(error)], + "schema": SUMMARY_SCHEMA, + "status": "FAIL", + } + passed = False + write_json(report_dir / "summary.json", summary) + write_status(report_dir / "status.txt", summary["status"]) + if not passed: + print(f"{args.campaign} qualification failed; see {report_dir / 'summary.json'}", file=sys.stderr) + return 1 + print(f"{args.campaign} qualification passed") + return 0 + + +def parse_arguments() -> argparse.Namespace: + """Parse the command-line contract used by the shell adapters and tests.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--campaign", choices=sorted(CAMPAIGNS), required=True) + parser.add_argument("--manifest", type=Path, required=True) + parser.add_argument("--module-root", type=Path, required=True) + parser.add_argument("--flow-root", type=Path, required=True) + parser.add_argument("--report-dir", type=Path, required=True) + parser.add_argument("--work-dir", type=Path, required=True) + return parser.parse_args() + + +if __name__ == "__main__": + sys.exit(execute(parse_arguments())) diff --git a/ci/release_manifest.py b/ci/release_manifest.py new file mode 100755 index 0000000..dc23adc --- /dev/null +++ b/ci/release_manifest.py @@ -0,0 +1,1090 @@ +#!/usr/bin/env python3 +"""Generate and validate auditable MOSAIC release evidence manifests.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import platform +import re +import shlex +import shutil +import subprocess +import sys +from collections import defaultdict +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + + +SCHEMA = "mosaic-release-evidence-v1" +NAME = re.compile(r"^[a-z][a-z0-9_]*$") +REVISION = re.compile(r"^[0-9a-fA-F]{40}$") +SHA256 = re.compile(r"^[0-9a-f]{64}$") +VALID_STATUSES = {"PASS", "SKIP"} + + +class ReleaseError(ValueError): + """Report a user-correctable release evidence error.""" + + +def json_environment(name: str, default: object) -> object: + """Decode one JSON environment variable with a caller-provided default.""" + raw_value = os.environ.get(name, "") + if not raw_value: + return default + try: + return json.loads(raw_value) + except json.JSONDecodeError as error: + raise ReleaseError( + f"{name} contains invalid JSON at column {error.colno}: {error.msg}" + ) from error + + +def environment_list(name: str) -> list[str]: + """Split one shell-style path or identifier list from the environment.""" + try: + return shlex.split(os.environ.get(name, "")) + except ValueError as error: + raise ReleaseError(f"Cannot parse {name}: {error}") from error + + +def git_output(repository: Path, arguments: list[str]) -> str: + """Run a read-only Git query and return trimmed standard output.""" + completed = subprocess.run( + ["git", "-C", str(repository), *arguments], + capture_output=True, + text=True, + check=False, + ) + if completed.returncode != 0: + diagnostic = (completed.stderr or completed.stdout).strip() + raise ReleaseError(f"Git query failed in {repository}: {diagnostic}") + return completed.stdout.strip() + + +def resolve_revision(supplied: str, repository: Path, label: str) -> str: + """Validate an explicit revision or resolve HEAD for local development.""" + if supplied: + if not REVISION.fullmatch(supplied): + raise ReleaseError(f"{label} revision must contain exactly 40 hex digits") + return supplied.lower() + ci_value = os.environ.get("CI", "").strip().lower() + if ci_value not in {"", "0", "false", "no"}: + raise ReleaseError(f"{label} revision must be supplied explicitly in CI") + revision = git_output(repository, ["rev-parse", "--verify", "HEAD^{commit}"]) + if not REVISION.fullmatch(revision): + raise ReleaseError(f"Git returned an invalid {label} revision: {revision!r}") + return revision.lower() + + +def source_tree_dirty(repository: Path) -> bool: + """Report tracked, staged, and untracked source changes.""" + return bool(git_output(repository, ["status", "--porcelain", "--untracked-files=normal"])) + + +def resolve_dirty_state(repository: Path, supplied: str, label: str) -> bool: + """Use an explicit packaged-source attestation or inspect a Git checkout.""" + normalized = supplied.strip().lower() + if normalized: + if normalized not in {"true", "false"}: + raise ReleaseError( + f"{label} dirty state must be true or false when supplied" + ) + return normalized == "true" + try: + return source_tree_dirty(repository) + except ReleaseError as error: + variable = f"RELEASE_{label.upper()}_DIRTY" + raise ReleaseError( + f"Cannot inspect {label} source tree; supply {variable}=true or false " + "for an immutable packaged checkout" + ) from error + + +def sha256_file(path: Path) -> str: + """Hash one file without loading it completely into memory.""" + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def relative_path(path: Path, module_root: Path) -> str: + """Return a repository-relative POSIX path or reject external evidence.""" + resolved = path.resolve() + try: + return resolved.relative_to(module_root).as_posix() + except ValueError as error: + raise ReleaseError( + f"Release input or evidence must be inside MODULE_ROOT: {resolved}" + ) from error + + +class InputCollector: + """Collect required release inputs and their reasons without duplicates.""" + + def __init__(self, module_root: Path) -> None: + self.module_root = module_root + self.roles: dict[Path, set[str]] = defaultdict(set) + self.visited_filelists: set[Path] = set() + + def add(self, declared_path: str | Path, role: str) -> None: + """Add one file or every file below one declared directory.""" + path = Path(declared_path) + if not path.is_absolute(): + path = self.module_root / path + if not path.exists(): + raise ReleaseError(f"Required release input is missing: {path}") + if path.is_dir(): + files = sorted( + candidate + for candidate in path.rglob("*") + if candidate.is_file() + and "__pycache__" not in candidate.parts + and candidate.suffix not in {".pyc", ".pyo"} + ) + if not files: + raise ReleaseError(f"Required release input directory is empty: {path}") + for candidate in files: + self.add(candidate, role) + return + resolved = path.resolve() + relative_path(resolved, self.module_root) + self.roles[resolved].add(role) + + def add_filelist(self, declared_path: str | Path) -> None: + """Add a portable filelist, nested lists, sources, and include trees.""" + path = Path(declared_path) + if not path.is_absolute(): + path = self.module_root / path + path = path.resolve() + if path in self.visited_filelists: + return + self.visited_filelists.add(path) + self.add(path, "filelist") + + try: + tokens = shlex.split(path.read_text(encoding="utf-8"), comments=True) + except (OSError, UnicodeError, ValueError) as error: + raise ReleaseError(f"Cannot parse release filelist {path}: {error}") from error + + token_index = 0 + while token_index < len(tokens): + token = tokens[token_index] + if token in {"-f", "-F"}: + token_index += 1 + if token_index >= len(tokens): + raise ReleaseError(f"Filelist option {token} has no path in {path}") + self.add_filelist(tokens[token_index]) + elif token.startswith("+incdir+"): + for include in token.removeprefix("+incdir+").split("+"): + self.add(include, "include") + elif token.startswith("+define+"): + pass + elif token.startswith(("+", "-")): + raise ReleaseError(f"Unsupported release filelist token {token!r} in {path}") + else: + self.add(token, "source") + token_index += 1 + + def entries(self) -> list[dict[str, object]]: + """Return sorted machine-readable path, role, and digest records.""" + return [ + { + "path": relative_path(path, self.module_root), + "roles": sorted(roles), + "sha256": sha256_file(path), + } + for path, roles in sorted( + self.roles.items(), key=lambda item: relative_path(item[0], self.module_root) + ) + ] + + +def flow_is_disabled(flow: str, disabled_flows: set[str]) -> bool: + """Apply the canonical disabled-flow policy, including the CDC alias.""" + return flow in disabled_flows or ( + "cdc" in disabled_flows and flow in {"vc_cdc", "sg_cdc"} + ) + + +def evidence_entry(path: Path, module_root: Path) -> dict[str, str]: + """Create a relative path and SHA-256 record for generated evidence.""" + if not path.is_file(): + raise ReleaseError(f"Required release evidence is missing: {path}") + return { + "path": relative_path(path, module_root), + "sha256": sha256_file(path), + } + + +def collect_flows( + module_root: Path, report_dir: Path +) -> tuple[list[dict[str, object]], set[str]]: + """Validate every canonical flow against its resolved required or skip policy.""" + flows = os.environ.get("MOSAIC_FLOW_IDS", "").split() + if not flows: + raise ReleaseError("MOSAIC_FLOW_IDS is empty") + if len(flows) != len(set(flows)): + raise ReleaseError("MOSAIC_FLOW_IDS contains duplicate flow names") + + disabled_flows = set(os.environ.get("DISABLED_FLOWS", "").split()) + entries: list[dict[str, object]] = [] + required_flows: set[str] = set() + for flow in flows: + status_path = report_dir / flow / "status.txt" + disabled = flow_is_disabled(flow, disabled_flows) + if disabled: + status = "SKIP" + status_evidence = None + if status_path.exists(): + status = status_path.read_text(encoding="utf-8").strip() + if status != "SKIP": + raise ReleaseError( + f"Disabled flow {flow} has status {status}, expected SKIP" + ) + status_evidence = evidence_entry(status_path, module_root) + entries.append( + { + "id": flow, + "policy": "approved_skip", + "status": "SKIP", + "status_evidence": status_evidence, + } + ) + continue + + required_flows.add(flow) + if not status_path.is_file(): + raise ReleaseError( + f"Required flow {flow} is missing status evidence: {status_path}" + ) + status = status_path.read_text(encoding="utf-8").strip() + if status != "PASS": + raise ReleaseError(f"Required flow {flow} has status {status}, expected PASS") + entries.append( + { + "id": flow, + "policy": "required", + "status": "PASS", + "status_evidence": evidence_entry(status_path, module_root), + } + ) + return entries, required_flows + + +def collect_supplemental_gates( + module_root: Path, report_dir: Path +) -> list[dict[str, object]]: + """Validate module-owned supplemental gates using the shared status contract.""" + gates = environment_list("RELEASE_SUPPLEMENTAL_GATES") + if len(gates) != len(set(gates)): + raise ReleaseError("RELEASE_SUPPLEMENTAL_GATES contains duplicate names") + entries = [] + for gate in gates: + if not NAME.fullmatch(gate): + raise ReleaseError(f"Invalid supplemental gate name: {gate!r}") + status_path = report_dir / gate / "status.txt" + if not status_path.is_file(): + raise ReleaseError( + f"Supplemental gate {gate} is missing status evidence: {status_path}" + ) + status = status_path.read_text(encoding="utf-8").strip() + if status != "PASS": + raise ReleaseError( + f"Supplemental gate {gate} has status {status}, expected PASS" + ) + entries.append( + { + "id": gate, + "status": status, + "status_evidence": evidence_entry(status_path, module_root), + } + ) + return entries + + +def run_version_command( + name: str, command_text: str, version_arguments: list[str], flows: set[str] +) -> tuple[dict[str, object], dict[str, str]]: + """Resolve one executable and capture its complete version response.""" + command = shlex.split(command_text) + if not command: + raise ReleaseError(f"Tool command for {name} is empty") + executable = command[0] + resolved = str(Path(executable).resolve()) if Path(executable).is_file() else shutil.which(executable) + if not resolved: + raise ReleaseError(f"Enabled flows require {name}, but {executable} is not in PATH") + completed = subprocess.run( + [resolved, *command[1:], *version_arguments], + capture_output=True, + text=True, + timeout=20, + check=False, + ) + version = (completed.stdout or completed.stderr).strip() + if completed.returncode != 0 or not version: + raise ReleaseError( + f"Cannot read {name} version with {' '.join([resolved, *version_arguments])}" + ) + return ( + { + "name": name, + "context": "flow", + "command": [Path(executable).name, *command[1:], *version_arguments], + "flows": sorted(flows), + "version": version, + }, + {"name": name, "context": "flow", "path": resolved}, + ) + + +def parse_version_log(path: Path) -> dict[str, str]: + """Parse key/value and heredoc fields emitted by the PyUVM adapter.""" + if not path.is_file(): + raise ReleaseError(f"PyUVM version evidence is missing: {path}") + lines = path.read_text(encoding="utf-8").splitlines() + values: dict[str, str] = {} + index = 0 + while index < len(lines): + line = lines[index] + if "<<" in line: + key, delimiter = line.split("<<", 1) + index += 1 + contents = [] + while index < len(lines) and lines[index] != delimiter: + contents.append(lines[index]) + index += 1 + if index >= len(lines): + raise ReleaseError(f"Unterminated version field {key} in {path}") + values[key] = "\n".join(contents) + elif "=" in line: + key, value = line.split("=", 1) + values[key] = value + index += 1 + return values + + +def collect_tools( + module_root: Path, report_dir: Path, required_flows: set[str] +) -> tuple[list[dict[str, object]], list[dict[str, str]]]: + """Capture exact portable tool versions and optional module-owned tools.""" + tools: list[dict[str, object]] = [ + { + "name": "python", + "context": "manifest_generator", + "command": [Path(sys.executable).name], + "flows": [], + "version": platform.python_version(), + } + ] + tool_paths = [ + { + "name": "python", + "context": "manifest_generator", + "path": str(Path(sys.executable).resolve()), + } + ] + specifications = [ + ("verible-verilog-lint", {"verible_lint"}, "VERIBLE_LINT_CMD", "verible-verilog-lint", ["--version"]), + ("verible-verilog-format", {"verible_format"}, "VERIBLE_FORMAT_CMD", "verible-verilog-format", ["--version"]), + ("slang", {"slang_elaboration"}, "SLANG_CMD", "slang", ["--version"]), + ("verilator", {"verilator_lint", "verilator_sim"}, "VERILATOR_CMD", "verilator", ["--version"]), + ("yosys", {"yosys_synthesis"}, "YOSYS_CMD", "yosys", ["--version"]), + ("symbiyosys", {"symbiyosys_formal"}, "SBY_CMD", "sby", ["--version"]), + ("eqy", {"eqy_equivalence"}, "EQY_CMD", "eqy", ["--version"]), + ("iverilog", {"four_state_qualification"}, "IVERILOG_CMD", "iverilog", ["-V"]), + ("vvp", {"four_state_qualification"}, "VVP_CMD", "vvp", ["-V"]), + ] + for name, owned_flows, variable, default, arguments in specifications: + active_flows = owned_flows & required_flows + if active_flows: + tool, tool_path = run_version_command( + name, os.environ.get(variable, default), arguments, active_flows + ) + tools.append(tool) + tool_paths.append(tool_path) + + for flow in ("pyuvm_open_source", "pyuvm_commercial"): + if flow not in required_flows: + continue + version_path = report_dir / flow / "versions.log" + values = parse_version_log(version_path) + required_fields = { + "python", + "pyuvm", + "cocotb", + "simulator", + "simulator_executable", + "simulator_version", + } + missing_fields = sorted(required_fields - set(values)) + if missing_fields: + raise ReleaseError( + f"PyUVM version evidence is missing fields: {', '.join(missing_fields)}" + ) + source = evidence_entry(version_path, module_root) + for name in ("python", "pyuvm", "cocotb"): + tools.append( + { + "name": name, + "context": flow, + "command": ["python"] if name == "python" else [], + "flows": [flow], + "source": source, + "version": values[name], + } + ) + tools.append( + { + "name": values["simulator"], + "context": flow, + "command": [Path(values["simulator_executable"]).name], + "flows": [flow], + "source": source, + "version": values["simulator_version"], + } + ) + tool_paths.extend( + [ + { + "name": "python", + "context": flow, + "path": os.environ.get("PYUVM_PYTHON", ""), + }, + { + "name": values["simulator"], + "context": flow, + "path": values["simulator_executable"], + }, + ] + ) + + custom_tools = json_environment("RELEASE_ADDITIONAL_TOOLS_JSON", []) + if not isinstance(custom_tools, list): + raise ReleaseError("RELEASE_ADDITIONAL_TOOLS_JSON must contain a JSON array") + for index, custom in enumerate(custom_tools): + if not isinstance(custom, dict) or set(custom) != {"name", "command", "flows"}: + raise ReleaseError( + f"Additional tool {index} must contain name, command, and flows" + ) + name = custom["name"] + command = custom["command"] + flows = custom["flows"] + if not isinstance(name, str) or not NAME.fullmatch(name): + raise ReleaseError(f"Additional tool {index} has invalid name") + if not isinstance(command, list) or not all( + isinstance(item, str) and item for item in command + ): + raise ReleaseError(f"Additional tool {name} command must be a string array") + if not isinstance(flows, list) or not all(isinstance(item, str) for item in flows): + raise ReleaseError(f"Additional tool {name} flows must be a string array") + active_flows = set(flows) & required_flows + if flows and not active_flows: + continue + tool, tool_path = run_version_command( + name, command[0], command[1:], active_flows + ) + tools.append(tool) + tool_paths.append(tool_path) + + key = lambda entry: (str(entry["name"]), str(entry["context"])) + return sorted(tools, key=key), sorted(tool_paths, key=key) + + +def collect_coverage(module_root: Path, report_dir: Path) -> list[dict[str, str]]: + """Index native HDL and PyUVM functional coverage as separate evidence.""" + candidates = [ + ("verilator_sim", "coverage.dat", "systemverilog_native_database"), + ("verilator_sim", "coverage.info", "systemverilog_native_report"), + ("pyuvm_open_source", "coverage.dat", "systemverilog_native_database"), + ("pyuvm_open_source", "coverage.info", "systemverilog_native_report"), + ("pyuvm_open_source", "functional-coverage.json", "pyuvm_functional"), + ("pyuvm_commercial", "functional-coverage.json", "pyuvm_functional"), + ( + "coverage_qualification", + "summary.json", + "coverage_qualification_summary", + ), + ] + entries = [] + for producer, filename, kind in candidates: + path = report_dir / producer / filename + if path.is_file(): + entries.append( + {"kind": kind, "producer": producer, **evidence_entry(path, module_root)} + ) + + additional = json_environment("RELEASE_COVERAGE_EVIDENCE_JSON", []) + if not isinstance(additional, list): + raise ReleaseError("RELEASE_COVERAGE_EVIDENCE_JSON must contain a JSON array") + for index, item in enumerate(additional): + if not isinstance(item, dict) or set(item) != {"kind", "producer", "path"}: + raise ReleaseError( + f"Additional coverage entry {index} must contain kind, producer, and path" + ) + if not all(isinstance(item[field], str) and item[field] for field in item): + raise ReleaseError(f"Additional coverage entry {index} has empty fields") + path = Path(item["path"]) + if not path.is_absolute(): + path = module_root / path + entries.append( + { + "kind": item["kind"], + "producer": item["producer"], + **evidence_entry(path, module_root), + } + ) + return sorted( + entries, + key=lambda entry: (entry["producer"], entry["kind"], entry["path"]), + ) + + +def collect_qualification_inputs( + collector: InputCollector, module_root: Path, required_flows: set[str] +) -> None: + """Hash module-owned collateral for each enabled qualification campaign.""" + campaign_by_flow = { + "negative_qualification": "negative", + "four_state_qualification": "four_state", + } + active = { + flow: campaign + for flow, campaign in campaign_by_flow.items() + if flow in required_flows + } + if not active: + return + manifest_path = Path( + os.environ.get( + "QUALIFICATION_CAMPAIGN_MANIFEST", + str(module_root / "config" / "qualification-campaigns.json"), + ) + ) + if not manifest_path.is_absolute(): + manifest_path = module_root / manifest_path + collector.add(manifest_path, "declared") + try: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError) as error: + raise ReleaseError( + f"Cannot parse qualification campaign inputs from {manifest_path}: {error}" + ) from error + campaigns = manifest.get("campaigns") if isinstance(manifest, dict) else None + if not isinstance(campaigns, dict): + raise ReleaseError("Qualification campaign manifest has no campaigns object") + for flow, campaign in active.items(): + selected = campaigns.get(campaign) + if not isinstance(selected, dict): + raise ReleaseError(f"Required flow {flow} has no {campaign} campaign") + inputs = selected.get("inputs", []) + if not isinstance(inputs, list) or not all( + isinstance(item, str) and item for item in inputs + ): + raise ReleaseError(f"Campaign {campaign} inputs must be a string array") + for declared_input in inputs: + collector.add(declared_input, "declared") + + +def collect_static_intent_inputs( + collector: InputCollector, module_root: Path, required_flows: set[str] +) -> None: + """Hash the expectation file and every SDC or UPF path it selects.""" + if "static_intent" not in required_flows: + return + config_path = Path( + os.environ.get( + "STATIC_INTENT_CONFIG", + str(module_root / "config" / "static-intent.json"), + ) + ) + if not config_path.is_absolute(): + config_path = module_root / config_path + collector.add(config_path, "declared") + try: + config = json.loads(config_path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError) as error: + raise ReleaseError( + f"Cannot parse static-intent inputs from {config_path}: {error}" + ) from error + if not isinstance(config, dict): + raise ReleaseError("Static-intent configuration root must be an object") + sdc = config.get("sdc", {}) + if not isinstance(sdc, dict): + raise ReleaseError("Static-intent sdc section must be an object") + profiles = sdc.get("profiles", []) + if not isinstance(profiles, list): + raise ReleaseError("Static-intent SDC profiles must be an array") + for index, profile in enumerate(profiles): + if not isinstance(profile, dict) or not isinstance(profile.get("path"), str): + raise ReleaseError(f"Static-intent SDC profile {index} has no path") + collector.add(profile["path"], "declared") + upf = config.get("upf") + if upf is not None: + if not isinstance(upf, dict) or not isinstance(upf.get("path"), str): + raise ReleaseError("Static-intent UPF section has no path") + collector.add(upf["path"], "declared") + + +def generate_manifest(module_root: Path, flow_root: Path, report_dir: Path) -> dict[str, object]: + """Validate release evidence and compose deterministic and volatile sections.""" + module_root = module_root.resolve() + flow_root = flow_root.resolve() + report_dir = report_dir.resolve() + module_name = os.environ.get("RELEASE_MODULE_NAME", "").strip() + if not NAME.fullmatch(module_name): + raise ReleaseError(f"Invalid release module name: {module_name!r}") + profile = os.environ.get("PROFILE", "default") or "default" + if not NAME.fullmatch(profile): + raise ReleaseError(f"Invalid release profile name: {profile!r}") + context = os.environ.get("RELEASE_EXECUTION_CONTEXT", "native") + if not NAME.fullmatch(context): + raise ReleaseError(f"Invalid release execution context: {context!r}") + + module_revision = resolve_revision( + os.environ.get("MODULE_REVISION", ""), module_root, "module" + ) + methodology_revision = resolve_revision( + os.environ.get("METHODOLOGY_REVISION", ""), flow_root, "methodology" + ) + module_dirty = resolve_dirty_state( + module_root, os.environ.get("RELEASE_MODULE_DIRTY", ""), "module" + ) + methodology_dirty = resolve_dirty_state( + flow_root, + os.environ.get("RELEASE_METHODOLOGY_DIRTY", ""), + "methodology", + ) + allow_dirty = os.environ.get("RELEASE_ALLOW_DIRTY", "disabled") + if allow_dirty not in {"enabled", "disabled"}: + raise ReleaseError("RELEASE_ALLOW_DIRTY must be enabled or disabled") + if (module_dirty or methodology_dirty) and allow_dirty != "enabled": + dirty_repositories = ", ".join( + name + for name, dirty in ( + ("module", module_dirty), + ("methodology", methodology_dirty), + ) + if dirty + ) + raise ReleaseError( + f"Dirty source tree detected for {dirty_repositories}; commit changes or " + "set RELEASE_ALLOW_DIRTY=enabled" + ) + + collector = InputCollector(module_root) + for path in environment_list("RELEASE_INPUT_FILES"): + collector.add(path, "declared") + for path in environment_list("RELEASE_FILELISTS"): + collector.add_filelist(path) + + flows, required_flows = collect_flows(module_root, report_dir) + collect_qualification_inputs(collector, module_root, required_flows) + collect_static_intent_inputs(collector, module_root, required_flows) + gates = collect_supplemental_gates(module_root, report_dir) + additional_evidence = [] + for declared_path in ( + environment_list("RELEASE_STANDARD_EVIDENCE") + + environment_list("RELEASE_ADDITIONAL_EVIDENCE") + ): + path = Path(declared_path) + if not path.is_absolute(): + path = module_root / path + additional_evidence.append(evidence_entry(path, module_root)) + for flow in ("negative_qualification", "four_state_qualification"): + summary_path = report_dir / flow / "summary.json" + if flow in required_flows and summary_path.is_file(): + additional_evidence.append(evidence_entry(summary_path, module_root)) + if "static_intent" in required_flows: + for filename in ("sdc-findings.json", "upf-findings.json", "summary.json"): + additional_evidence.append( + evidence_entry(report_dir / "static_intent" / filename, module_root) + ) + + parameters = json_environment("PROFILE_PARAMETERS_JSON", {}) + technology_details = json_environment("RELEASE_TECHNOLOGY_METADATA_JSON", {}) + metadata = json_environment("RELEASE_METADATA_JSON", {}) + execution_metadata = json_environment("RELEASE_EXECUTION_METADATA_JSON", {}) + for variable, value in ( + ("PROFILE_PARAMETERS_JSON", parameters), + ("RELEASE_TECHNOLOGY_METADATA_JSON", technology_details), + ("RELEASE_METADATA_JSON", metadata), + ("RELEASE_EXECUTION_METADATA_JSON", execution_metadata), + ): + if not isinstance(value, dict): + raise ReleaseError(f"{variable} must contain a JSON object") + + methodology_version_path = flow_root / "VERSION" + if not methodology_version_path.is_file(): + raise ReleaseError(f"Methodology VERSION is missing: {methodology_version_path}") + technology = { + "name": os.environ.get("RELEASE_TECHNOLOGY", "technology-independent"), + "details": technology_details, + } + if not technology["name"]: + raise ReleaseError("RELEASE_TECHNOLOGY cannot be empty") + tools, tool_paths = collect_tools(module_root, report_dir, required_flows) + + return { + "schema": SCHEMA, + "deterministic": { + "identity": { + "module": module_name, + "profile": profile, + "module_revision": module_revision, + "methodology_revision": methodology_revision, + "methodology_version": methodology_version_path.read_text( + encoding="utf-8" + ).strip(), + "execution_context": context, + }, + "parameters": parameters, + "technology": technology, + "flows": flows, + "supplemental_gates": gates, + "tools": tools, + "inputs": collector.entries(), + "evidence": { + "additional": sorted(additional_evidence, key=lambda item: item["path"]), + "coverage": collect_coverage(module_root, report_dir), + }, + "metadata": metadata, + }, + "volatile": { + "generated_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), + "source_tree": { + "module_dirty": module_dirty, + "methodology_dirty": methodology_dirty, + }, + "execution": { + "context": context, + "host": { + "machine": platform.machine(), + "platform": platform.platform(), + "python_executable": str(Path(sys.executable).resolve()), + }, + "tool_paths": tool_paths, + "metadata": execution_metadata, + }, + }, + } + + +def validate_evidence_record(record: object, label: str) -> None: + """Validate one path and digest record shared by several sections.""" + if not isinstance(record, dict): + raise ReleaseError(f"{label} must be an object") + path = record.get("path") + digest = record.get("sha256") + if ( + not isinstance(path, str) + or not path + or Path(path).is_absolute() + or ".." in Path(path).parts + ): + raise ReleaseError(f"{label} has an invalid repository-relative path") + if not isinstance(digest, str) or not SHA256.fullmatch(digest): + raise ReleaseError(f"{label} has an invalid SHA-256 digest") + + +def validate_manifest(manifest: object) -> None: + """Validate the stable structural and semantic release manifest contract.""" + if not isinstance(manifest, dict) or set(manifest) != { + "schema", + "deterministic", + "volatile", + }: + raise ReleaseError("Release manifest root has invalid fields") + if manifest["schema"] != SCHEMA: + raise ReleaseError(f"Release manifest schema must be {SCHEMA!r}") + deterministic = manifest["deterministic"] + volatile = manifest["volatile"] + if not isinstance(deterministic, dict) or not isinstance(volatile, dict): + raise ReleaseError("Release manifest sections must be objects") + + required_sections = { + "identity", + "parameters", + "technology", + "flows", + "supplemental_gates", + "tools", + "inputs", + "evidence", + "metadata", + } + if set(deterministic) != required_sections: + raise ReleaseError("Release manifest deterministic section has invalid fields") + identity = deterministic["identity"] + if not isinstance(identity, dict) or set(identity) != { + "module", + "profile", + "module_revision", + "methodology_revision", + "methodology_version", + "execution_context", + }: + raise ReleaseError("Release identity must be an object") + for field in ("module_revision", "methodology_revision"): + if not isinstance(identity.get(field), str) or not REVISION.fullmatch( + identity[field] + ): + raise ReleaseError(f"Release identity has invalid {field}") + for field in ("module", "profile", "execution_context"): + if not isinstance(identity.get(field), str) or not NAME.fullmatch(identity[field]): + raise ReleaseError(f"Release identity has invalid {field}") + if ( + not isinstance(identity.get("methodology_version"), str) + or not identity["methodology_version"] + ): + raise ReleaseError("Release identity has invalid methodology_version") + + if not isinstance(deterministic["parameters"], dict): + raise ReleaseError("Release parameters must be an object") + if not isinstance(deterministic["metadata"], dict): + raise ReleaseError("Release metadata must be an object") + technology = deterministic["technology"] + if ( + not isinstance(technology, dict) + or set(technology) != {"name", "details"} + or not isinstance(technology["name"], str) + or not technology["name"] + or not isinstance(technology["details"], dict) + ): + raise ReleaseError("Release technology context is invalid") + + flows = deterministic["flows"] + if not isinstance(flows, list) or not flows: + raise ReleaseError("Release manifest must record canonical flows") + flow_ids = [] + for index, flow in enumerate(flows): + if not isinstance(flow, dict): + raise ReleaseError(f"Flow entry {index} must be an object") + if set(flow) != {"id", "policy", "status", "status_evidence"}: + raise ReleaseError(f"Flow entry {index} has invalid fields") + flow_ids.append(flow.get("id")) + if not isinstance(flow.get("id"), str) or not NAME.fullmatch(flow["id"]): + raise ReleaseError(f"Flow entry {index} has invalid ID") + if flow.get("status") not in VALID_STATUSES: + raise ReleaseError(f"Flow entry {index} has invalid status") + expected_policy = "required" if flow["status"] == "PASS" else "approved_skip" + if flow.get("policy") != expected_policy: + raise ReleaseError(f"Flow entry {index} has inconsistent policy") + evidence = flow.get("status_evidence") + if flow["status"] == "PASS" and evidence is None: + raise ReleaseError(f"Required flow entry {index} has no status evidence") + if evidence is not None: + validate_evidence_record(evidence, f"flow entry {index} status evidence") + if len(flow_ids) != len(set(flow_ids)): + raise ReleaseError("Release manifest repeats canonical flow IDs") + + for section in ("supplemental_gates", "inputs", "tools"): + if not isinstance(deterministic[section], list): + raise ReleaseError(f"Release manifest {section} must be an array") + for index, item in enumerate(deterministic["supplemental_gates"]): + if ( + not isinstance(item, dict) + or set(item) != {"id", "status", "status_evidence"} + or not isinstance(item.get("id"), str) + or not NAME.fullmatch(item["id"]) + or item.get("status") != "PASS" + ): + raise ReleaseError(f"Supplemental gate entry {index} is invalid") + validate_evidence_record(item.get("status_evidence"), f"supplemental gate {index}") + for index, item in enumerate(deterministic["inputs"]): + if not isinstance(item, dict) or set(item) != {"path", "roles", "sha256"}: + raise ReleaseError(f"Input entry {index} has invalid fields") + validate_evidence_record(item, f"input entry {index}") + if ( + not isinstance(item.get("roles"), list) + or not item["roles"] + or not all( + isinstance(role, str) and role in {"declared", "filelist", "include", "source"} + for role in item["roles"] + ) + ): + raise ReleaseError(f"Input entry {index} has no roles") + + for index, item in enumerate(deterministic["tools"]): + if not isinstance(item, dict): + raise ReleaseError(f"Tool entry {index} must be an object") + required_tool_fields = {"name", "context", "command", "flows", "version"} + if not required_tool_fields.issubset(item) or not set(item).issubset( + required_tool_fields | {"source"} + ): + raise ReleaseError(f"Tool entry {index} has invalid fields") + if not isinstance(item["name"], str) or not item["name"]: + raise ReleaseError(f"Tool entry {index} has invalid name") + if not isinstance(item["context"], str) or not NAME.fullmatch(item["context"]): + raise ReleaseError(f"Tool entry {index} has invalid context") + if not isinstance(item["command"], list) or not all( + isinstance(argument, str) and argument for argument in item["command"] + ): + raise ReleaseError(f"Tool entry {index} has invalid command") + if not isinstance(item["flows"], list) or not all( + isinstance(flow, str) and NAME.fullmatch(flow) for flow in item["flows"] + ): + raise ReleaseError(f"Tool entry {index} has invalid flows") + if not isinstance(item["version"], str) or not item["version"]: + raise ReleaseError(f"Tool entry {index} has invalid version") + if "source" in item: + validate_evidence_record(item["source"], f"tool entry {index} source") + + evidence = deterministic["evidence"] + if not isinstance(evidence, dict) or set(evidence) != {"additional", "coverage"}: + raise ReleaseError("Release evidence section has invalid fields") + for group in ("additional", "coverage"): + if not isinstance(evidence[group], list): + raise ReleaseError(f"Release evidence {group} must be an array") + for index, item in enumerate(evidence[group]): + expected_fields = {"path", "sha256"} + if group == "coverage": + expected_fields |= {"kind", "producer"} + if not isinstance(item, dict) or set(item) != expected_fields: + raise ReleaseError(f"{group} evidence entry {index} has invalid fields") + validate_evidence_record(item, f"{group} evidence entry {index}") + if group == "coverage" and not all( + isinstance(item[field], str) and item[field] + for field in ("kind", "producer") + ): + raise ReleaseError(f"Coverage evidence entry {index} has invalid labels") + + if set(volatile) != {"generated_at", "source_tree", "execution"}: + raise ReleaseError("Release manifest volatile section has invalid fields") + source_tree = volatile.get("source_tree") + if not isinstance(source_tree, dict) or set(source_tree) != { + "module_dirty", + "methodology_dirty", + } or not all(isinstance(value, bool) for value in source_tree.values()): + raise ReleaseError("Release manifest has invalid dirty-tree state") + generated_at = volatile.get("generated_at") + if not isinstance(generated_at, str) or not generated_at.endswith("Z"): + raise ReleaseError("Release manifest has invalid generation time") + try: + datetime.fromisoformat(generated_at.removesuffix("Z") + "+00:00") + except ValueError as error: + raise ReleaseError("Release manifest has invalid generation time") from error + + execution = volatile.get("execution") + if not isinstance(execution, dict) or set(execution) != { + "context", + "host", + "tool_paths", + "metadata", + }: + raise ReleaseError("Release execution context has invalid fields") + if execution["context"] != identity["execution_context"]: + raise ReleaseError("Deterministic and volatile execution contexts disagree") + host = execution["host"] + if not isinstance(host, dict) or set(host) != { + "machine", + "platform", + "python_executable", + }: + raise ReleaseError("Release execution host has invalid fields") + if not all(isinstance(value, str) and value for value in host.values()): + raise ReleaseError("Release execution host has empty fields") + if not isinstance(execution["metadata"], dict): + raise ReleaseError("Release execution metadata must be an object") + if not isinstance(execution["tool_paths"], list): + raise ReleaseError("Release execution tool_paths must be an array") + for index, tool_path in enumerate(execution["tool_paths"]): + if ( + not isinstance(tool_path, dict) + or set(tool_path) != {"name", "context", "path"} + or not all(isinstance(tool_path[field], str) and tool_path[field] + for field in ("name", "context", "path")) + ): + raise ReleaseError(f"Release execution tool path {index} is invalid") + + +def write_summary(manifest: dict[str, object], path: Path) -> None: + """Write a compact reviewer-oriented summary beside the JSON manifest.""" + deterministic = manifest["deterministic"] + volatile = manifest["volatile"] + identity = deterministic["identity"] + lines = [ + "MOSAIC release evidence", + f"Module: {identity['module']}", + f"Profile: {identity['profile']}", + f"Execution context: {identity['execution_context']}", + f"Module revision: {identity['module_revision']}", + f"Methodology revision: {identity['methodology_revision']}", + f"Generated at: {volatile['generated_at']}", + f"Dirty module tree: {str(volatile['source_tree']['module_dirty']).lower()}", + "Dirty methodology tree: " + f"{str(volatile['source_tree']['methodology_dirty']).lower()}", + "", + "Flows:", + ] + lines.extend( + f" {flow['status']:4} {flow['id']} ({flow['policy']})" + for flow in deterministic["flows"] + ) + if deterministic["supplemental_gates"]: + lines.extend(["", "Supplemental gates:"]) + lines.extend( + f" {gate['status']:4} {gate['id']}" + for gate in deterministic["supplemental_gates"] + ) + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def parser() -> argparse.ArgumentParser: + """Build the release manifest generator and validator CLI.""" + result = argparse.ArgumentParser(description=__doc__) + subparsers = result.add_subparsers(dest="command", required=True) + generate = subparsers.add_parser("generate") + generate.add_argument("--module-root", required=True, type=Path) + generate.add_argument("--flow-root", required=True, type=Path) + generate.add_argument("--report-dir", required=True, type=Path) + generate.add_argument("--output-dir", required=True, type=Path) + validate = subparsers.add_parser("validate") + validate.add_argument("--manifest", required=True, type=Path) + return result + + +def main() -> int: + """Generate new evidence or validate an archived manifest.""" + arguments = parser().parse_args() + output_dir = arguments.output_dir.resolve() if arguments.command == "generate" else None + try: + if arguments.command == "validate": + manifest = json.loads(arguments.manifest.read_text(encoding="utf-8")) + validate_manifest(manifest) + print(f"Validated release manifest: {arguments.manifest.resolve()}") + return 0 + + manifest = generate_manifest( + arguments.module_root, arguments.flow_root, arguments.report_dir + ) + validate_manifest(manifest) + output_dir.mkdir(parents=True, exist_ok=True) + manifest_path = output_dir / "manifest.json" + summary_path = output_dir / "summary.txt" + temporary_path = output_dir / "manifest.json.tmp" + temporary_path.write_text( + json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + temporary_path.replace(manifest_path) + write_summary(manifest, summary_path) + (output_dir / "status.txt").write_text("PASS\n", encoding="utf-8") + print(f"Release evidence manifest written to {manifest_path}") + return 0 + except (ReleaseError, OSError, subprocess.SubprocessError, json.JSONDecodeError) as error: + if output_dir is not None: + output_dir.mkdir(parents=True, exist_ok=True) + for stale_name in ("manifest.json", "manifest.json.tmp", "summary.txt"): + (output_dir / stale_name).unlink(missing_ok=True) + (output_dir / "status.txt").write_text("FAIL\n", encoding="utf-8") + print(f"release manifest error: {error}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/ci/static_intent.py b/ci/static_intent.py new file mode 100755 index 0000000..3716f06 --- /dev/null +++ b/ci/static_intent.py @@ -0,0 +1,1098 @@ +#!/usr/bin/env python3 +"""Validate portable SDC and UPF intent without executing module-owned Tcl.""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from dataclasses import dataclass +from decimal import Decimal, InvalidOperation +from pathlib import Path +from typing import Any + + +SCHEMA = "mosaic-static-intent-v1" +RESULT_SCHEMA = "mosaic-static-intent-result-v1" +NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_.-]*$") + +SDC_OPTIONS = { + "create_clock": ({"-name", "-period", "-waveform"}, {"-add"}), + "create_generated_clock": ( + { + "-name", + "-source", + "-master_clock", + "-divide_by", + "-multiply_by", + "-edges", + "-edge_shift", + "-duty_cycle", + }, + {"-add", "-combinational", "-invert"}, + ), + "set_clock_uncertainty": ( + {"-from", "-to", "-rise_from", "-rise_to", "-fall_from", "-fall_to"}, + {"-setup", "-hold", "-rise", "-fall"}, + ), + "set_input_delay": ( + {"-clock", "-reference_pin"}, + {"-add_delay", "-clock_fall", "-level_sensitive", "-max", "-min", "-network_latency_included", "-source_latency_included"}, + ), + "set_output_delay": ( + {"-clock", "-reference_pin"}, + {"-add_delay", "-clock_fall", "-level_sensitive", "-max", "-min", "-network_latency_included", "-source_latency_included"}, + ), + "set_max_delay": ({"-from", "-to", "-through"}, {"-datapath_only", "-ignore_clock_latency", "-rise", "-fall"}), + "set_false_path": ( + {"-from", "-to", "-through", "-rise_from", "-rise_to", "-fall_from", "-fall_to"}, + {"-setup", "-hold", "-rise", "-fall"}, + ), + "set_clock_groups": ({"-group", "-name"}, {"-asynchronous", "-exclusive", "-logically_exclusive", "-physically_exclusive", "-allow_paths"}), + "set_input_transition": (set(), {"-rise", "-fall", "-max", "-min"}), + "set_load": (set(), {"-subtract_pin_load", "-pin_load", "-wire_load", "-max", "-min"}), + "set_case_analysis": (set(), set()), +} + +UPF_OPTIONS = { + "set_design_top": (set(), set()), + "create_power_domain": ({"-elements", "-scope"}, {"-include_scope", "-atomic"}), + "create_supply_port": ({"-domain", "-direction"}, set()), + "create_supply_net": ({"-domain", "-resolve"}, {"-reuse"}), + "connect_supply_net": ({"-ports", "-pins"}, {"-reuse"}), + "set_domain_supply_net": ({"-primary_power_net", "-primary_ground_net", "-secondary_power_net", "-secondary_ground_net"}, set()), + "add_port_state": ({"-state"}, set()), + "create_pst": ({"-supplies"}, set()), + "add_pst_state": ({"-pst", "-state"}, set()), + "add_power_state": ({"-state", "-supply_expr", "-logic_expr", "-simstate"}, set()), + "set_port_attributes": ({"-ports", "-elements", "-driver_supply", "-receiver_supply", "-feedthrough"}, set()), + "set_isolation": ({"-domain", "-applies_to", "-clamp_value", "-isolation_power_net", "-isolation_ground_net", "-location", "-elements", "-source", "-sink", "-diff_supply_only"}, {"-no_isolation"}), + "set_isolation_control": ({"-domain", "-isolation_signal", "-isolation_sense", "-location"}, set()), + "set_level_shifter": ({"-domain", "-applies_to", "-rule", "-location", "-elements", "-source", "-sink", "-input_supply_set", "-output_supply_set", "-threshold"}, {"-no_shift"}), + "set_retention": ({"-domain", "-retention_power_net", "-retention_ground_net", "-save_signal", "-restore_signal", "-elements"}, set()), + "set_retention_control": ({"-domain", "-save_signal", "-restore_signal"}, set()), + "create_power_switch": ({"-domain", "-input_supply_port", "-output_supply_port", "-control_port", "-on_state", "-off_state", "-ack_port"}, set()), +} + +QUERY_COMMANDS = { + "get_ports", + "get_clocks", + "get_pins", + "get_cells", + "all_inputs", + "all_outputs", + "remove_from_collection", +} + +POLICY_REQUIREMENTS = { + "sequential": {"create_clock", "set_clock_uncertainty", "set_input_delay", "set_output_delay"}, + "combinational": {"set_max_delay"}, + "clock_gating": {"create_clock", "create_generated_clock", "set_clock_uncertainty", "set_input_delay"}, + "reset_synchronizer": {"create_clock", "set_clock_uncertainty", "set_output_delay"}, +} + +POLICY_FORBIDDEN = { + "sequential": set(), + "combinational": {"create_clock", "create_generated_clock", "set_false_path", "set_clock_groups"}, + "clock_gating": {"set_false_path", "set_clock_groups", "set_output_delay"}, + "reset_synchronizer": {"set_clock_groups"}, +} + +STRATEGY_COMMANDS = { + "isolation": "set_isolation", + "level_shifting": "set_level_shifter", + "retention": "set_retention", + "power_switch": "create_power_switch", +} + + +class IntentError(ValueError): + """Report invalid configuration or unsupported portable Tcl syntax.""" + + +@dataclass +class Call: + """One captured SDC or UPF command.""" + + command: str + arguments: list[dict[str, Any]] + source: str + line: int + + def canonical(self) -> str: + """Return a stable representation for duplicate and consistency checks.""" + return json.dumps( + { + "command": self.command, + "arguments": [canonical_value(value) for value in self.arguments], + }, + sort_keys=True, + separators=(",", ":"), + ) + + +def canonical_value(value: dict[str, Any]) -> dict[str, Any]: + """Normalize numeric literals recursively while retaining Tcl value kinds.""" + if value.get("kind") == "literal": + raw = str(value["value"]) + try: + normalized = format(Decimal(raw).normalize(), "f") + except InvalidOperation: + normalized = raw + return {"kind": "literal", "value": normalized} + if value.get("kind") == "query": + return { + "kind": "query", + "command": value["command"], + "arguments": [ + canonical_value(argument) for argument in value.get("arguments", []) + ], + } + return value + + +def finding(code: str, message: str, source: str = "", line: int = 0) -> dict[str, Any]: + """Build one actionable error finding.""" + result: dict[str, Any] = {"code": code, "severity": "error", "message": message} + if source: + result["source"] = source + if line: + result["line"] = line + return result + + +def split_commands(text: str, path: Path) -> list[tuple[str, int]]: + """Split a Tcl file into commands without evaluating substitutions.""" + text = re.sub(r"\\\r?\n[ \t]*", " ", text) + commands: list[tuple[str, int]] = [] + buffer: list[str] = [] + braces = 0 + brackets = 0 + quoted = False + escaped = False + line = 1 + command_line = 1 + only_space = True + index = 0 + while index < len(text): + character = text[index] + if escaped: + buffer.append(character) + escaped = False + elif character == "\\": + buffer.append(character) + escaped = True + elif character == '"' and braces == 0: + quoted = not quoted + buffer.append(character) + only_space = False + elif not quoted and character == "{" and brackets >= 0: + braces += 1 + buffer.append(character) + only_space = False + elif not quoted and character == "}" and braces: + braces -= 1 + buffer.append(character) + elif not quoted and braces == 0 and character == "[": + brackets += 1 + buffer.append(character) + only_space = False + elif not quoted and braces == 0 and character == "]": + if brackets == 0: + raise IntentError(f"{path}:{line}: unmatched closing bracket") + brackets -= 1 + buffer.append(character) + elif not quoted and braces == 0 and brackets == 0 and character == "#" and only_space: + while index < len(text) and text[index] != "\n": + index += 1 + if index >= len(text): + break + character = "\n" + elif not quoted and braces == 0 and brackets == 0 and character in {"\n", ";"}: + command = "".join(buffer).strip() + if command: + commands.append((command, command_line)) + buffer = [] + only_space = True + command_line = line + (character == "\n") + else: + buffer.append(character) + if not character.isspace(): + only_space = False + if character == "\n": + line += 1 + if not buffer: + command_line = line + index += 1 + if quoted or braces or brackets: + raise IntentError(f"{path}:{line}: unterminated Tcl grouping") + command = "".join(buffer).strip() + if command: + commands.append((command, command_line)) + return commands + + +def split_words(command: str, source: str, line: int) -> list[str]: + """Split one Tcl command while preserving grouped and nested words.""" + words: list[str] = [] + start: int | None = None + braces = 0 + brackets = 0 + quoted = False + escaped = False + for index, character in enumerate(command): + if start is None: + if character.isspace(): + continue + start = index + if escaped: + escaped = False + elif character == "\\": + escaped = True + elif character == '"' and braces == 0: + quoted = not quoted + elif not quoted and character == "{" and brackets == 0: + braces += 1 + elif not quoted and character == "}" and brackets == 0: + braces -= 1 + elif not quoted and braces == 0 and character == "[": + brackets += 1 + elif not quoted and braces == 0 and character == "]": + brackets -= 1 + elif character.isspace() and not quoted and braces == 0 and brackets == 0: + words.append(command[start:index]) + start = None + if braces < 0 or brackets < 0: + raise IntentError(f"{source}:{line}: malformed Tcl grouping") + if start is not None: + words.append(command[start:]) + if quoted or braces or brackets: + raise IntentError(f"{source}:{line}: malformed Tcl word") + return words + + +def list_items(text: str, source: str, line: int) -> list[str]: + """Decode the literal Tcl lists used by the portable subset.""" + values = split_words(text, source, line) + result = [] + for value in values: + if value.startswith("{") and value.endswith("}"): + value = value[1:-1].strip() + if any(marker in value for marker in ("[", "]", "$", ";")): + raise IntentError(f"{source}:{line}: substitutions are not supported inside literal lists") + result.append(value.strip('"')) + return result + + +def parse_value(word: str, source: str, line: int) -> dict[str, Any]: + """Parse one literal, list, or supported collection expression.""" + if "$" in word: + raise IntentError(f"{source}:{line}: variable substitution is unsupported") + if word.startswith("{") and word.endswith("}"): + return {"kind": "list", "items": list_items(word[1:-1], source, line)} + if word.startswith("[") and word.endswith("]"): + nested_words = split_words(word[1:-1].strip(), source, line) + if not nested_words: + raise IntentError(f"{source}:{line}: empty command substitution") + command = nested_words[0] + if command not in QUERY_COMMANDS: + raise IntentError(f"{source}:{line}: unsupported query command {command}") + arguments = [parse_value(item, source, line) for item in nested_words[1:]] + expected = {"all_inputs": 0, "all_outputs": 0, "remove_from_collection": 2} + if command in expected and len(arguments) != expected[command]: + raise IntentError( + f"{source}:{line}: {command} expects {expected[command]} arguments" + ) + if command in {"get_ports", "get_clocks", "get_pins", "get_cells"} and not arguments: + raise IntentError(f"{source}:{line}: {command} requires an object pattern") + return {"kind": "query", "command": command, "arguments": arguments} + if word.startswith('"') and word.endswith('"'): + word = word[1:-1] + if any(marker in word for marker in ("[", "]", "{", "}", ";")): + raise IntentError(f"{source}:{line}: unsupported Tcl word {word!r}") + return {"kind": "literal", "value": word.replace("\\ ", " ")} + + +def parse_file(path: Path, language: str) -> list[Call]: + """Capture every command in one SDC or UPF file.""" + try: + text = path.read_text(encoding="utf-8") + except OSError as error: + raise IntentError(f"Cannot read {path}: {error}") from error + supported = SDC_OPTIONS if language == "sdc" else UPF_OPTIONS + calls = [] + for command_text, line in split_commands(text, path): + words = split_words(command_text, str(path), line) + command = words[0] + if command not in supported: + raise IntentError(f"{path}:{line}: unsupported {language.upper()} command {command}") + arguments = [parse_value(word, str(path), line) for word in words[1:]] + validate_options(command, arguments, supported[command], path, line) + calls.append(Call(command, arguments, str(path), line)) + return calls + + +def literal(value: dict[str, Any], label: str) -> str: + """Return a literal value or reject a selector/list at a scalar position.""" + if value.get("kind") != "literal" or not value.get("value"): + raise IntentError(f"{label} must be a nonempty literal") + return str(value["value"]) + + +def validate_options( + command: str, + arguments: list[dict[str, Any]], + specification: tuple[set[str], set[str]], + path: Path, + line: int, +) -> None: + """Reject unsupported options and missing option values.""" + value_options, flags = specification + index = 0 + while index < len(arguments): + value = arguments[index] + if value.get("kind") == "literal" and str(value.get("value", "")).startswith("-"): + option = str(value["value"]) + if option in flags: + index += 1 + continue + if option not in value_options: + try: + Decimal(option) + except InvalidOperation as error: + raise IntentError( + f"{path}:{line}: unsupported option {option} for {command}" + ) from error + index += 1 + continue + if index + 1 >= len(arguments): + raise IntentError(f"{path}:{line}: option {option} has no value") + index += 2 + continue + index += 1 + + +def decoded(call: Call) -> tuple[list[dict[str, Any]], dict[str, list[dict[str, Any]]], set[str]]: + """Separate positional values, repeated options, and flags.""" + value_options, valid_flags = (SDC_OPTIONS | UPF_OPTIONS)[call.command] + positional: list[dict[str, Any]] = [] + options: dict[str, list[dict[str, Any]]] = {} + flags: set[str] = set() + index = 0 + while index < len(call.arguments): + value = call.arguments[index] + if value.get("kind") == "literal" and value.get("value") in valid_flags: + flags.add(str(value["value"])) + index += 1 + elif value.get("kind") == "literal" and value.get("value") in value_options: + option = str(value["value"]) + options.setdefault(option, []).append(call.arguments[index + 1]) + index += 2 + else: + positional.append(value) + index += 1 + return positional, options, flags + + +def scalar_option(call: Call, name: str, required: bool = False) -> str | None: + """Read one non-repeated literal option.""" + _, options, _ = decoded(call) + values = options.get(name, []) + if len(values) > 1: + raise IntentError(f"{call.source}:{call.line}: {call.command} repeats {name}") + if not values: + if required: + raise IntentError(f"{call.source}:{call.line}: {call.command} requires {name}") + return None + return literal(values[0], f"{call.command} {name}") + + +def positional_name(call: Call) -> str: + """Return the first positional resource name.""" + positional, _, _ = decoded(call) + if not positional: + raise IntentError(f"{call.source}:{call.line}: {call.command} requires a name") + return literal(positional[0], call.command) + + +def value_items(value: dict[str, Any]) -> list[str]: + """Return names from a literal or literal-list value.""" + if value.get("kind") == "literal": + return [str(value["value"])] + if value.get("kind") == "list": + return [str(item) for item in value["items"]] + raise IntentError("Expected a literal object list") + + +def query_names(value: dict[str, Any], query: str) -> list[str] | None: + """Return concrete names selected by one collection query.""" + if value.get("kind") != "query" or value.get("command") != query: + return None + names: list[str] = [] + for argument in value.get("arguments", []): + names.extend(value_items(argument)) + return names + + +def selector_covers(value: dict[str, Any], port: str, direction: str) -> bool: + """Determine whether a supported selector constrains one expected port.""" + direct = query_names(value, "get_ports") + if direct is not None: + return port in direct + if value.get("kind") != "query": + return False + command = value.get("command") + if command == f"all_{direction}s": + return True + if command == "remove_from_collection": + arguments = value.get("arguments", []) + if len(arguments) != 2 or not selector_covers(arguments[0], port, direction): + return False + removed = query_names(arguments[1], "get_ports") + return removed is not None and port not in removed + return False + + +def numeric_equal(first: str, second: object) -> bool: + """Compare numeric policy values without formatting sensitivity.""" + try: + return Decimal(first) == Decimal(str(second)) + except InvalidOperation: + return False + + +def call_identity(call: Call) -> tuple[str, str]: + """Build a semantic identity used to detect conflicting declarations.""" + if call.command in {"set_design_top"}: + return call.command, "singleton" + if call.command in { + "create_clock", + "create_generated_clock", + "create_power_domain", + "create_supply_port", + "create_supply_net", + "create_pst", + "set_isolation", + "set_level_shifter", + "set_retention", + "create_power_switch", + }: + name = scalar_option(call, "-name") if call.command in {"create_clock", "create_generated_clock"} else None + return call.command, name or positional_name(call) + if call.command == "set_domain_supply_net": + return call.command, positional_name(call) + if call.command in { + "set_clock_uncertainty", + "set_input_delay", + "set_output_delay", + "set_max_delay", + }: + positional, options, flags = decoded(call) + selector_options = { + option: values + for option, values in options.items() + if option in {"-clock", "-from", "-to", "-through"} + } + identity = json.dumps( + { + "selector_options": [ + (option, [canonical_value(value) for value in values]) + for option, values in sorted(selector_options.items()) + ], + "selector": canonical_value(positional[-1]) if len(positional) > 1 else None, + "flags": sorted(flags), + }, + sort_keys=True, + ) + return call.command, identity + return call.command, call.canonical() + + +def duplicate_findings(calls: list[Call]) -> list[dict[str, Any]]: + """Detect exact duplicate and conflicting singleton/resource commands.""" + results: list[dict[str, Any]] = [] + exact: dict[str, Call] = {} + identities: dict[tuple[str, str], Call] = {} + connected_objects: dict[str, tuple[str, Call]] = {} + for call in calls: + canonical = call.canonical() + if canonical in exact: + results.append( + finding( + "duplicate_command", + f"Duplicate {call.command} repeats line {exact[canonical].line}", + call.source, + call.line, + ) + ) + continue + exact[canonical] = call + identity = call_identity(call) + if identity in identities and identities[identity].canonical() != canonical: + results.append( + finding( + "conflicting_command", + f"Conflicting {call.command} declaration for {identity[1]!r}; first declared at line {identities[identity].line}", + call.source, + call.line, + ) + ) + else: + identities[identity] = call + if call.command == "connect_supply_net": + net = positional_name(call) + _, options, _ = decoded(call) + for option in ("-ports", "-pins"): + for value in options.get(option, []): + objects = query_names(value, "get_ports") + if objects is None: + objects = value_items(value) + for connected in objects: + previous = connected_objects.get(connected) + if previous and previous[0] != net: + results.append( + finding( + "conflicting_command", + f"Supply object {connected} is connected to both {previous[0]} and {net}", + call.source, + call.line, + ) + ) + else: + connected_objects[connected] = (net, call) + return results + + +def require_string_array(value: object, label: str) -> list[str]: + """Validate one unique string array from the expectation file.""" + if not isinstance(value, list) or not all(isinstance(item, str) and item for item in value): + raise IntentError(f"{label} must be a string array") + if len(value) != len(set(value)): + raise IntentError(f"{label} contains duplicate values") + return value + + +def resolve_module_path(module_root: Path, declared: object, label: str) -> Path: + """Resolve and confine one module-owned configuration path.""" + if not isinstance(declared, (str, Path)) or not str(declared): + raise IntentError(f"{label} must be a nonempty path") + declared_path = Path(declared) + if declared_path.is_absolute(): + path = declared_path.resolve() + else: + working_path = declared_path.resolve() + path = working_path if working_path.exists() else (module_root / declared_path).resolve() + try: + path.relative_to(module_root.resolve()) + except ValueError as error: + raise IntentError(f"{label} must be inside MODULE_ROOT: {path}") from error + return path + + +def validate_sdc_profile(profile: dict[str, Any], module_root: Path) -> tuple[list[Call], list[dict[str, Any]]]: + """Validate one named SDC profile and its interface policy.""" + allowed = { + "name", + "path", + "kind", + "expected_ports", + "clocks", + "generated_clocks", + "intentional_exceptions", + "forbidden_commands", + } + unknown = sorted(set(profile) - allowed) + if unknown: + raise IntentError(f"SDC profile has unknown fields: {', '.join(unknown)}") + name = profile.get("name") + kind = profile.get("kind") + if not isinstance(name, str) or not NAME.fullmatch(name): + raise IntentError("SDC profile name is invalid") + if kind not in POLICY_REQUIREMENTS: + raise IntentError(f"SDC profile {name} has an unsupported kind") + path = resolve_module_path(module_root, profile.get("path"), f"SDC profile {name} path") + calls = parse_file(path, "sdc") + results = duplicate_findings(calls) + commands = {call.command for call in calls} + for required in sorted(POLICY_REQUIREMENTS[kind] - commands): + results.append(finding("missing_command", f"{name} ({kind}) requires {required}", str(path))) + if kind == "combinational" and "set_max_delay" in commands: + complete_path = False + for call in calls: + if call.command != "set_max_delay": + continue + _, options, _ = decoded(call) + from_values = options.get("-from", []) + to_values = options.get("-to", []) + if ( + len(from_values) == 1 + and from_values[0].get("command") == "all_inputs" + and len(to_values) == 1 + and to_values[0].get("command") == "all_outputs" + ): + complete_path = True + if not complete_path: + results.append( + finding( + "incomplete_max_delay", + f"{name} requires set_max_delay from all inputs to all outputs", + str(path), + ) + ) + forbidden = POLICY_FORBIDDEN[kind] | set( + require_string_array(profile.get("forbidden_commands", []), f"SDC profile {name} forbidden_commands") + ) + for call in calls: + if call.command in forbidden: + results.append(finding("forbidden_command", f"{name} forbids {call.command}", call.source, call.line)) + + expected_ports = profile.get("expected_ports", {}) + if not isinstance(expected_ports, dict) or set(expected_ports) - {"clock", "input_delay", "output_delay"}: + raise IntentError(f"SDC profile {name} expected_ports is invalid") + port_commands = {"clock": "create_clock", "input_delay": "set_input_delay", "output_delay": "set_output_delay"} + directions = {"clock": "input", "input_delay": "input", "output_delay": "output"} + for role, command in port_commands.items(): + ports = require_string_array(expected_ports.get(role, []), f"SDC profile {name} expected_ports.{role}") + matching = [call for call in calls if call.command == command] + for port in ports: + covered = False + for call in matching: + positional, _, _ = decoded(call) + if positional and selector_covers(positional[-1], port, directions[role]): + covered = True + break + if not covered: + results.append(finding("missing_port_constraint", f"{name} does not constrain expected {role} port {port}", str(path))) + + clocks = profile.get("clocks", []) + if not isinstance(clocks, list): + raise IntentError(f"SDC profile {name} clocks must be an array") + for expected in clocks: + if not isinstance(expected, dict) or set(expected) != {"name", "period", "port"}: + raise IntentError(f"SDC profile {name} clock expectation is invalid") + matched = False + for call in calls: + if call.command != "create_clock": + continue + positional, _, _ = decoded(call) + clock_name = scalar_option(call, "-name", required=True) + period = scalar_option(call, "-period", required=True) + if clock_name == expected["name"] and numeric_equal(period or "", expected["period"]) and positional and selector_covers(positional[-1], str(expected["port"]), "input"): + matched = True + if not matched: + results.append(finding("missing_clock", f"{name} lacks expected clock {expected['name']} on {expected['port']}", str(path))) + + generated = profile.get("generated_clocks", []) + if not isinstance(generated, list): + raise IntentError(f"SDC profile {name} generated_clocks must be an array") + for expected in generated: + required_fields = {"name", "source_port", "output_port", "combinational"} + if not isinstance(expected, dict) or set(expected) != required_fields or not isinstance(expected["combinational"], bool): + raise IntentError(f"SDC profile {name} generated-clock expectation is invalid") + matched = False + for call in calls: + if call.command != "create_generated_clock": + continue + positional, options, flags = decoded(call) + source_values = options.get("-source", []) + if ( + scalar_option(call, "-name", required=True) == expected["name"] + and len(source_values) == 1 + and query_names(source_values[0], "get_ports") == [expected["source_port"]] + and positional + and selector_covers(positional[-1], str(expected["output_port"]), "output") + and ("-combinational" in flags) == expected["combinational"] + ): + matched = True + if not matched: + results.append(finding("missing_generated_clock", f"{name} lacks expected generated clock {expected['name']}", str(path))) + + expected_exceptions = profile.get("intentional_exceptions", []) + if not isinstance(expected_exceptions, list): + raise IntentError(f"SDC profile {name} intentional_exceptions must be an array") + normalized_expected = [] + for exception in expected_exceptions: + if not isinstance(exception, dict) or set(exception) != {"command", "from_ports", "to_ports"} or exception["command"] != "set_false_path": + raise IntentError(f"SDC profile {name} has an invalid intentional exception") + normalized_expected.append( + ( + exception["command"], + tuple(require_string_array(exception["from_ports"], "exception from_ports")), + tuple(require_string_array(exception["to_ports"], "exception to_ports")), + ) + ) + actual_exceptions = [] + for call in calls: + if call.command != "set_false_path": + continue + _, options, _ = decoded(call) + from_values = options.get("-from", []) + to_values = options.get("-to", []) + from_ports = query_names(from_values[0], "get_ports") if len(from_values) == 1 else None + to_ports = query_names(to_values[0], "get_ports") if len(to_values) == 1 else [] + if from_ports is None or to_ports is None: + results.append(finding("broad_exception", f"{name} contains a non-port or broad false path", call.source, call.line)) + continue + actual_exceptions.append(("set_false_path", tuple(from_ports), tuple(to_ports))) + for exception in actual_exceptions: + if exception not in normalized_expected: + results.append(finding("undeclared_exception", f"{name} contains an undeclared asynchronous exception {exception}", str(path))) + for exception in normalized_expected: + if exception not in actual_exceptions: + results.append(finding("missing_exception", f"{name} lacks declared asynchronous exception {exception}", str(path))) + return calls, results + + +def validate_sdc(section: object, module_root: Path) -> tuple[dict[str, list[Call]], list[dict[str, Any]]]: + """Validate all SDC profiles and cross-profile consistency rules.""" + if not isinstance(section, dict) or set(section) - {"profiles", "consistency"}: + raise IntentError("sdc must contain profiles and optional consistency") + profiles = section.get("profiles") + if not isinstance(profiles, list) or not profiles: + raise IntentError("sdc.profiles must be a nonempty array") + calls_by_profile: dict[str, list[Call]] = {} + results: list[dict[str, Any]] = [] + for profile in profiles: + if not isinstance(profile, dict): + raise IntentError("Each SDC profile must be an object") + name = str(profile.get("name", "")) + if name in calls_by_profile: + raise IntentError(f"Duplicate SDC profile name {name}") + calls, profile_findings = validate_sdc_profile(profile, module_root) + calls_by_profile[name] = calls + results.extend(profile_findings) + consistency = section.get("consistency", []) + if not isinstance(consistency, list): + raise IntentError("sdc.consistency must be an array") + for rule in consistency: + if not isinstance(rule, dict) or set(rule) != {"profiles", "commands"}: + raise IntentError("Each SDC consistency rule must contain profiles and commands") + names = require_string_array(rule["profiles"], "consistency profiles") + commands = require_string_array(rule["commands"], "consistency commands") + if len(names) < 2: + raise IntentError("A consistency rule requires at least two profiles") + if any(name not in calls_by_profile for name in names): + raise IntentError("A consistency rule names an unknown SDC profile") + baseline = sorted(call.canonical() for call in calls_by_profile[names[0]] if call.command in commands) + for name in names[1:]: + candidate = sorted(call.canonical() for call in calls_by_profile[name] if call.command in commands) + if candidate != baseline: + results.append(finding("profile_mismatch", f"SDC profiles {names[0]} and {name} differ for commands: {', '.join(commands)}")) + return calls_by_profile, results + + +def named_calls(calls: list[Call], command: str) -> dict[str, Call]: + """Index named UPF declarations after structural parsing.""" + return {positional_name(call): call for call in calls if call.command == command} + + +def option_items(call: Call, option: str) -> list[str]: + """Return all literal/list items attached to a repeated option.""" + _, options, _ = decoded(call) + result: list[str] = [] + for value in options.get(option, []): + result.extend(value_items(value)) + return result + + +def validate_upf(section: object, module_root: Path) -> tuple[list[Call], list[dict[str, Any]]]: + """Validate portable UPF resources, references, states, and strategies.""" + if not isinstance(section, dict) or set(section) != {"path", "top", "mode", "required", "forbidden_strategies"}: + raise IntentError("upf must contain path, top, mode, required, and forbidden_strategies") + if not isinstance(section["top"], str) or not NAME.fullmatch(section["top"]): + raise IntentError("upf.top is invalid") + if section["mode"] not in {"always_on", "boundary"}: + raise IntentError("upf.mode must be always_on or boundary") + path = resolve_module_path(module_root, section["path"], "UPF path") + calls = parse_file(path, "upf") + results = duplicate_findings(calls) + tops = [positional_name(call) for call in calls if call.command == "set_design_top"] + if tops != [section["top"]]: + results.append(finding("design_top_mismatch", f"Expected one set_design_top {section['top']}, found {tops}", str(path))) + + required = section["required"] + required_keys = { + "power_domains", + "supply_ports", + "supply_nets", + "supply_connections", + "domain_supplies", + "port_states", + "power_state_tables", + "power_states", + "port_attribute_ports", + "isolation", + "level_shifting", + "retention", + } + if not isinstance(required, dict) or set(required) - required_keys: + raise IntentError("upf.required contains an unknown category") + expected = {key: require_string_array(required.get(key, []), f"upf.required.{key}") for key in required_keys} + + resources = { + "power_domains": named_calls(calls, "create_power_domain"), + "supply_ports": named_calls(calls, "create_supply_port"), + "supply_nets": named_calls(calls, "create_supply_net"), + "power_state_tables": named_calls(calls, "create_pst"), + "isolation": named_calls(calls, "set_isolation"), + "level_shifting": named_calls(calls, "set_level_shifter"), + "retention": named_calls(calls, "set_retention"), + } + for category, declarations in resources.items(): + for name in expected[category]: + if name not in declarations: + results.append(finding("missing_intent", f"UPF lacks required {category} declaration {name}", str(path))) + + domains = set(resources["power_domains"]) + supply_ports = set(resources["supply_ports"]) + supply_nets = set(resources["supply_nets"]) + domain_supplies = named_calls(calls, "set_domain_supply_net") + connections = named_calls(calls, "connect_supply_net") + for name in expected["domain_supplies"]: + if name not in domain_supplies: + results.append(finding("missing_intent", f"UPF lacks domain supply assignment for {name}", str(path))) + for name in expected["supply_connections"]: + if name not in connections: + results.append(finding("missing_intent", f"UPF lacks supply connection for {name}", str(path))) + for call in calls: + if call.command in {"create_supply_port", "create_supply_net"}: + domain = scalar_option(call, "-domain") + if domain and domain not in domains: + results.append(finding("unknown_reference", f"{call.command} references unknown domain {domain}", call.source, call.line)) + elif call.command == "connect_supply_net": + net = positional_name(call) + if net not in supply_nets: + results.append(finding("unknown_reference", f"connect_supply_net references unknown net {net}", call.source, call.line)) + elif call.command == "set_domain_supply_net": + domain = positional_name(call) + if domain not in domains: + results.append(finding("unknown_reference", f"set_domain_supply_net references unknown domain {domain}", call.source, call.line)) + for option in ("-primary_power_net", "-primary_ground_net", "-secondary_power_net", "-secondary_ground_net"): + net = scalar_option(call, option) + if net and net not in supply_nets: + results.append(finding("unknown_reference", f"{option} references unknown supply net {net}", call.source, call.line)) + + port_state_names: set[str] = set() + states_by_supply: dict[str, set[str]] = {} + for call in calls: + if call.command != "add_port_state": + continue + supply = positional_name(call) + if supply not in supply_ports and supply not in supply_nets: + results.append(finding("unknown_reference", f"add_port_state references unknown supply {supply}", call.source, call.line)) + states: set[str] = set() + _, options, _ = decoded(call) + for state in options.get("-state", []): + items = value_items(state) + if len(items) < 2: + results.append(finding("incomplete_power_state", f"Port state on {supply} must contain a name and value", call.source, call.line)) + else: + states.add(items[0]) + port_state_names.add(f"{supply}.{items[0]}") + states_by_supply.setdefault(supply, set()).update(states) + for expected_state in expected["port_states"]: + if expected_state not in port_state_names: + results.append(finding("missing_intent", f"UPF lacks required port state {expected_state}", str(path))) + + power_states: set[str] = set() + pst_supplies: dict[str, list[str]] = {} + for name, call in resources["power_state_tables"].items(): + supplies = option_items(call, "-supplies") + pst_supplies[name] = supplies + if not supplies: + results.append(finding("incomplete_power_state", f"Power-state table {name} has no supplies", call.source, call.line)) + for supply in supplies: + if supply not in states_by_supply: + results.append(finding("incomplete_power_state", f"Power-state table {name} supply {supply} has no port states", call.source, call.line)) + if not any( + candidate.command == "add_pst_state" + and scalar_option(candidate, "-pst") == name + for candidate in calls + ): + results.append(finding("incomplete_power_state", f"Power-state table {name} has no states", call.source, call.line)) + for call in calls: + if call.command == "add_pst_state": + state = positional_name(call) + pst = scalar_option(call, "-pst", required=True) or "" + values = option_items(call, "-state") + power_states.add(f"{pst}.{state}") + if pst not in pst_supplies: + results.append(finding("unknown_reference", f"Power state {state} references unknown table {pst}", call.source, call.line)) + elif len(values) != len(pst_supplies[pst]): + results.append(finding("incomplete_power_state", f"Power state {pst}.{state} has {len(values)} values for {len(pst_supplies[pst])} supplies", call.source, call.line)) + else: + for supply, value in zip(pst_supplies[pst], values): + if value not in states_by_supply.get(supply, set()): + results.append(finding("unknown_reference", f"Power state {pst}.{state} uses unknown state {value} for {supply}", call.source, call.line)) + elif call.command == "add_power_state": + target = positional_name(call) + _, options, _ = decoded(call) + states = options.get("-state", []) + if not states: + results.append(finding("incomplete_power_state", f"add_power_state for {target} has no -state", call.source, call.line)) + for state in states: + items = value_items(state) + if not items: + results.append(finding("incomplete_power_state", f"add_power_state for {target} has an empty state", call.source, call.line)) + else: + power_states.add(f"{target}.{items[0]}") + for expected_state in expected["power_states"]: + if expected_state not in power_states: + results.append(finding("missing_intent", f"UPF lacks required power state {expected_state}", str(path))) + + attributed_ports: set[str] = set() + for call in calls: + if call.command == "set_port_attributes": + _, options, _ = decoded(call) + for value in options.get("-ports", []): + ports = query_names(value, "get_ports") + attributed_ports.update(ports if ports is not None else value_items(value)) + for port in expected["port_attribute_ports"]: + if port not in attributed_ports: + results.append(finding("missing_intent", f"UPF lacks required port attributes for {port}", str(path))) + + strategy_names = {category: set(resources[category]) for category in ("isolation", "level_shifting", "retention")} + strategy_names["power_switch"] = set(named_calls(calls, "create_power_switch")) + forbidden = set(require_string_array(section["forbidden_strategies"], "upf.forbidden_strategies")) + unknown_strategies = forbidden - set(STRATEGY_COMMANDS) + if unknown_strategies: + raise IntentError(f"Unknown forbidden UPF strategies: {', '.join(sorted(unknown_strategies))}") + if section["mode"] == "always_on": + forbidden |= set(STRATEGY_COMMANDS) + for strategy in sorted(forbidden): + for strategy_name in sorted(strategy_names[strategy]): + call = named_calls(calls, STRATEGY_COMMANDS[strategy])[strategy_name] + results.append(finding("forbidden_strategy", f"UPF forbids {strategy} strategy {strategy_name}", call.source, call.line)) + + mandatory_options = { + "set_isolation": {"-domain", "-applies_to", "-clamp_value"}, + "set_level_shifter": {"-domain", "-applies_to", "-rule"}, + "set_retention": {"-domain", "-retention_power_net", "-retention_ground_net", "-save_signal", "-restore_signal"}, + } + for call in calls: + if call.command not in mandatory_options: + continue + _, options, _ = decoded(call) + missing = sorted(mandatory_options[call.command] - set(options)) + if missing: + results.append(finding("incomplete_strategy", f"{call.command} {positional_name(call)} lacks options: {', '.join(missing)}", call.source, call.line)) + domain = scalar_option(call, "-domain") + if domain and domain not in domains: + results.append(finding("unknown_reference", f"{call.command} references unknown domain {domain}", call.source, call.line)) + isolation_controls = set(named_calls(calls, "set_isolation_control")) + for name, call in resources["isolation"].items(): + if name not in isolation_controls: + results.append(finding("incomplete_strategy", f"Isolation strategy {name} has no set_isolation_control", call.source, call.line)) + return calls, results + + +def load_configuration(path: Path) -> dict[str, Any]: + """Load and structurally validate the module-owned expectation file.""" + try: + config = json.loads(path.read_text(encoding="utf-8")) + except OSError as error: + raise IntentError(f"Cannot read static-intent configuration {path}: {error}") from error + except json.JSONDecodeError as error: + raise IntentError(f"Static-intent JSON is invalid at column {error.colno}: {error.msg}") from error + if not isinstance(config, dict) or set(config) - {"schema", "sdc", "upf"}: + raise IntentError("Static-intent root has unknown fields") + if config.get("schema") != SCHEMA: + raise IntentError(f"Static-intent schema must be {SCHEMA}") + if "sdc" not in config and "upf" not in config: + raise IntentError("Static-intent configuration must define sdc, upf, or both") + return config + + +def report_section( + kind: str, configured: bool, calls: int, findings: list[dict[str, Any]] +) -> dict[str, Any]: + """Create one deterministic SDC or UPF findings report.""" + status = "FAIL" if findings else "PASS" if configured else "SKIP" + return { + "schema": RESULT_SCHEMA, + "kind": kind, + "status": status, + "commands_captured": calls, + "findings": findings, + } + + +def write_json(path: Path, value: object) -> None: + """Write stable JSON evidence.""" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def validate(config_path: Path, module_root: Path, output_dir: Path) -> int: + """Run configured SDC and UPF checks and write complete evidence.""" + sdc_findings: list[dict[str, Any]] = [] + upf_findings: list[dict[str, Any]] = [] + sdc_call_count = 0 + upf_call_count = 0 + sdc_configured = False + upf_configured = False + try: + config_path = resolve_module_path( + module_root, config_path, "Static-intent configuration" + ) + config = load_configuration(config_path) + if "sdc" in config: + sdc_configured = True + try: + profiles, sdc_findings = validate_sdc(config["sdc"], module_root) + sdc_call_count = sum(len(calls) for calls in profiles.values()) + except IntentError as error: + sdc_findings = [finding("invalid_sdc_intent", str(error))] + if "upf" in config: + upf_configured = True + try: + calls, upf_findings = validate_upf(config["upf"], module_root) + upf_call_count = len(calls) + except IntentError as error: + upf_findings = [finding("invalid_upf_intent", str(error))] + except IntentError as error: + sdc_findings = [finding("invalid_configuration", str(error))] + + sdc_report = report_section("sdc", sdc_configured, sdc_call_count, sdc_findings) + upf_report = report_section("upf", upf_configured, upf_call_count, upf_findings) + write_json(output_dir / "sdc-findings.json", sdc_report) + write_json(output_dir / "upf-findings.json", upf_report) + all_findings = sdc_findings + upf_findings + try: + configuration_label = str(config_path.resolve().relative_to(module_root.resolve())) + except ValueError: + configuration_label = str(config_path.resolve()) + summary = { + "schema": RESULT_SCHEMA, + "kind": "aggregate", + "status": "PASS" if not all_findings else "FAIL", + "configuration": configuration_label, + "sdc": {"status": sdc_report["status"], "findings": len(sdc_findings)}, + "upf": {"status": upf_report["status"], "findings": len(upf_findings)}, + } + write_json(output_dir / "summary.json", summary) + (output_dir / "status.txt").write_text(f"{summary['status']}\n", encoding="utf-8") + for item in all_findings: + location = f"{item.get('source', '')}:{item.get('line', '')}".strip(":") + print(f"{location + ': ' if location else ''}{item['code']}: {item['message']}", file=sys.stderr) + return 0 if not all_findings else 1 + + +def build_parser() -> argparse.ArgumentParser: + """Build the command-line interface.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--config", required=True, type=Path) + parser.add_argument("--module-root", required=True, type=Path) + parser.add_argument("--output-dir", required=True, type=Path) + return parser + + +def main() -> int: + """Run the command-line validator.""" + arguments = build_parser().parse_args() + return validate(arguments.config, arguments.module_root, arguments.output_dir) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/config/flows.mk b/config/flows.mk index 75763a1..e5dd6f8 100644 --- a/config/flows.mk +++ b/config/flows.mk @@ -11,6 +11,10 @@ MOSAIC_FLOW_IDS := \ eqy_equivalence \ verilator_sim \ pyuvm_open_source \ + coverage_qualification \ + negative_qualification \ + four_state_qualification \ + static_intent \ openroad \ vcs_sim \ pyuvm_commercial \ @@ -44,6 +48,14 @@ FLOW_eqy_equivalence ?= enabled FLOW_verilator_sim ?= enabled # FLOW_pyuvm_open_source: Run PyUVM through Verilator or Icarus when opted in. FLOW_pyuvm_open_source ?= disabled +# FLOW_coverage_qualification: Enforce module-owned HDL and formal coverage policy. +FLOW_coverage_qualification ?= disabled +# FLOW_negative_qualification: Prove declared faults and invalid configurations are detected. +FLOW_negative_qualification ?= disabled +# FLOW_four_state_qualification: Detect declared X/Z values with pinned Icarus simulation. +FLOW_four_state_qualification ?= disabled +# FLOW_static_intent: Validate portable module-owned SDC and UPF semantics. +FLOW_static_intent ?= disabled # FLOW_openroad: Run optional PDK-backed physical implementation with OpenROAD. FLOW_openroad ?= enabled # FLOW_vcs_sim: Run the module-owned SystemVerilog testbench with VCS. @@ -89,6 +101,16 @@ FLOW_DEPENDENCIES_eqy_equivalence ?= yosys_synthesis FLOW_DEPENDENCIES_verilator_sim ?= # FLOW_DEPENDENCIES_pyuvm_open_source: PyUVM builds its simulator model itself. FLOW_DEPENDENCIES_pyuvm_open_source ?= +# FLOW_DEPENDENCIES_coverage_qualification: Normal simulation produces the +# default native coverage source. Override this with pyuvm_open_source when +# COVERAGE_QUALIFICATION_SOURCE selects PyUVM, or clear it for a dedicated run. +FLOW_DEPENDENCIES_coverage_qualification ?= verilator_sim +# FLOW_DEPENDENCIES_negative_qualification: Campaign cases own their prerequisites. +FLOW_DEPENDENCIES_negative_qualification ?= +# FLOW_DEPENDENCIES_four_state_qualification: Four-state cases compile independently. +FLOW_DEPENDENCIES_four_state_qualification ?= +# FLOW_DEPENDENCIES_static_intent: Static intent reads module-owned policy and Tcl only. +FLOW_DEPENDENCIES_static_intent ?= # FLOW_DEPENDENCIES_openroad: OpenROAD owns its build graph by default. FLOW_DEPENDENCIES_openroad ?= # FLOW_DEPENDENCIES_vcs_sim: VCS simulation builds from source. diff --git a/config/tool-versions.env b/config/tool-versions.env index a481a44..95058c4 100644 --- a/config/tool-versions.env +++ b/config/tool-versions.env @@ -12,3 +12,6 @@ SHELLCHECK_VERSION=0.11.0 SHELLCHECK_SHA256=8c3be12b05d5c177a04c29e3c78ce89ac86f1595681cab149b65b97c4e227198 ACTIONLINT_VERSION=1.7.12 ACTIONLINT_SHA256=8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 +# OpenROAD Flow Scripts is selected by an immutable OCI digest. +ORFS_IMAGE_REPOSITORY=openroad/orfs +ORFS_IMAGE_DIGEST=sha256:d995618be9f2bcdfa5538b885123463070dfbf178bea1818716d4652fe0fa380 diff --git a/config/tools.mk b/config/tools.mk index 7f4cda3..17d30df 100644 --- a/config/tools.mk +++ b/config/tools.mk @@ -32,14 +32,47 @@ export YOSYS_CMD ?=yosys export SBY_CMD ?=sby # EQY_CMD: EQY executable used for RTL-to-netlist equivalence. export EQY_CMD ?=eqy +# IVERILOG_CMD: Pinned OSS CAD Suite compiler used by four-state qualification. +export IVERILOG_CMD ?=iverilog +# VVP_CMD: Icarus runtime used by four-state qualification cases. +export VVP_CMD ?=vvp # SLANG_CMD: Slang executable used for SystemVerilog elaboration. export SLANG_CMD ?=slang # VERIBLE_LINT_CMD: Verible executable used for syntax and style linting. export VERIBLE_LINT_CMD ?=verible-verilog-lint # VERIBLE_FORMAT_CMD: Verible executable used for formatting checks. export VERIBLE_FORMAT_CMD ?=verible-verilog-format -# OPENROAD_CMD: OpenROAD executable used by the physical implementation adapter. +# VERIBLE_FORMAT_ARGS: Optional whitespace-separated formatting policy arguments, +# such as --indentation_spaces=4, supplied by the selected module profile. +export VERIBLE_FORMAT_ARGS ?= +# VERIBLE_FORMAT_PATHS: Whitespace-separated module-owned files or directories +# checked by the formatter. Multi-module profiles should narrow the default paths. +export VERIBLE_FORMAT_PATHS ?=rtl verif +# OPENROAD_CMD: OpenROAD executable reserved for direct tool integrations. export OPENROAD_CMD ?=openroad +# OPENROAD_EXECUTION_MODE: ORFS backend. local uses OPENROAD_FLOW_ROOT and +# container uses the immutable OPENROAD_ORFS_IMAGE through the selected runtime. +export OPENROAD_EXECUTION_MODE ?=local +# OPENROAD_FLOW_ROOT: Qualified local OpenROAD-flow-scripts checkout. Required +# only when OPENROAD_EXECUTION_MODE is local. +export OPENROAD_FLOW_ROOT ?= +# OPENROAD_CONTAINER_RUNTIME: OCI-compatible command used for container mode. +export OPENROAD_CONTAINER_RUNTIME ?=docker +# OPENROAD_ORFS_IMAGE: Immutable ORFS image assembled from the pinned repository +# and digest in tool-versions.env. Overrides must retain the @sha256 form. +export OPENROAD_ORFS_IMAGE ?=$(ORFS_IMAGE_REPOSITORY)@$(ORFS_IMAGE_DIGEST) +# OPENROAD_PLATFORM: ORFS public or site-owned platform selected by the module. +export OPENROAD_PLATFORM ?=nangate45 +# OPENROAD_FLOW_VARIANT: Stable ORFS output namespace for this configuration. +export OPENROAD_FLOW_VARIANT ?=base +# OPENROAD_DESIGN_NAME: ORFS design nickname and output directory component. +export OPENROAD_DESIGN_NAME ?=$(DESIGN_TOP) +# OPENROAD_CONSTRAINT_FILE: Exact module-owned SDC used by ORFS and evidence. +export OPENROAD_CONSTRAINT_FILE ?=$(SYNTHESIS_CONSTRAINT_FILE) +# OPENROAD_EVIDENCE_POLICY: Module-owned artifact and metric acceptance policy. +export OPENROAD_EVIDENCE_POLICY ?=$(MODULE_ROOT)/config/openroad-evidence.json +# OPENROAD_EVIDENCE_TOOL: Shared policy validator and evidence normalizer. +export OPENROAD_EVIDENCE_TOOL ?=$(FLOW_ROOT)/ci/openroad_evidence.py # VC_LINT_BIN: VC SpyGlass executable used by the licensed lint adapter. export VC_LINT_BIN ?=vc_static_shell # VC_CDC_BIN: VC SpyGlass executable used by the licensed CDC adapter. @@ -72,6 +105,21 @@ export FORMAL_COVER_CONFIG ?= export SIM_COVERAGE ?=enabled # VERILATOR_COVERAGE_CMD: Utility used to export coverage.dat as coverage.info. export VERILATOR_COVERAGE_CMD ?=verilator_coverage +# COVERAGE_QUALIFICATION_POLICY: Module-owned declarative coverage requirements. +export COVERAGE_QUALIFICATION_POLICY ?=$(MODULE_ROOT)/config/coverage-policy.json +# COVERAGE_QUALIFICATION_SOURCE: Existing evidence producer or dedicated rerun. +# Accepted values are verilator_sim, pyuvm_open_source, and dedicated. +export COVERAGE_QUALIFICATION_SOURCE ?=verilator_sim +# COVERAGE_QUALIFICATION_TOOL: Shared policy validator and evidence normalizer. +export COVERAGE_QUALIFICATION_TOOL ?=$(FLOW_ROOT)/ci/coverage_qualification.py +# QUALIFICATION_CAMPAIGN_MANIFEST: Module-owned negative and four-state case declarations. +export QUALIFICATION_CAMPAIGN_MANIFEST ?=$(MODULE_ROOT)/config/qualification-campaigns.json +# QUALIFICATION_CAMPAIGN_TOOL: Shared declarative campaign validator and runner. +export QUALIFICATION_CAMPAIGN_TOOL ?=$(FLOW_ROOT)/ci/qualification_campaign.py +# STATIC_INTENT_CONFIG: Module-owned SDC and UPF expectation policy. +export STATIC_INTENT_CONFIG ?=$(MODULE_ROOT)/config/static-intent.json +# STATIC_INTENT_TOOL: License-independent semantic SDC and UPF validator. +export STATIC_INTENT_TOOL ?=$(FLOW_ROOT)/ci/static_intent.py # PYUVM_PYTHON: Python interpreter from the pinned PyUVM virtual environment. export PYUVM_PYTHON ?=$(PYUVM_ROOT)/bin/python diff --git a/docs/README.md b/docs/README.md index c560664..48acb3d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,13 +12,27 @@ maintainers, and contributors to the methodology itself. repository to `mosaic-flow` and run the first checks. 2. [Architecture](architecture.md) explains the repository hierarchy, ownership boundary, and execution model. -3. [Configuration](configuration.md) is the reference for module variables, +3. [Multi-module projects](multi-module-projects.md) defines manifests, module + selection, concurrent execution, isolated evidence, and CI matrices. +4. [Parameter profiles](parameter-profiles.md) defines representative + elaborations, backend overrides, evidence isolation, and profile matrices. +5. [Configuration](configuration.md) is the reference for module variables, flow states, dependencies, tools, and overrides. -4. [Flow catalog](flows.md) describes every open-source and commercial flow, +6. [Flow catalog](flows.md) describes every open-source and commercial flow, including inputs, outputs, and upstream documentation. -5. [Results and quality gates](results-and-quality-gates.md) defines statuses, - waivers, generated artifacts, CI behavior, and release evidence. -6. [Methodology development](development.md) explains how to change, test, +7. [Coverage qualification](coverage-qualification.md) defines HDL thresholds, + named coverpoints, reviewed exclusions, and formal reachability. +8. [Negative-test and four-state qualification](qualification-campaigns.md) + defines declarative fault campaigns, controls, diagnostics, and evidence. +9. [Containerized OpenROAD](openroad.md) defines local and pinned-container + execution, physical evidence policy, isolation, and the signoff boundary. +10. [Results and quality gates](results-and-quality-gates.md) defines statuses, + waivers, generated artifacts, and CI behavior. +11. [Portable SDC and UPF intent](static-intent.md) defines the declarative + constraint and power-intent gate and its signoff boundary. +12. [Release evidence](release-evidence.md) defines the manifest schema, + acceptance policy, extension points, and release integration. +13. [Methodology development](development.md) explains how to change, test, qualify, version, and release this repository. ## Quick reference @@ -29,11 +43,22 @@ maintainers, and contributors to the methodology itself. | Validate resolved flow policy | `make flow-config-check` | | Prepare pinned open-source tools | `make setup-open-source` | | Run the portable quality gate | `make open-source` | +| Validate a module registry | `make module-manifest-check` | +| Generate a CI module matrix | `make module-matrix` | +| Run every registered module | `make all-modules TARGET=open-source` | +| Validate parameter profiles | `make profile-manifest-check` | +| Run every parameter profile | `make all-profiles PROFILE_TARGET=open-source` | +| Generate a module/profile matrix | `make module-profile-matrix` | +| Generate release evidence | `make release-manifest release-manifest-validate` | | Run one check | See the [flow catalog](flows.md) | | Run all licensed local checks | `make synopsys-all` | | Select or disable flows | Module `config/flows.mk` | | Set design paths and names | Module `config/design.mk` | | Understand PyUVM and SVA reuse | [PyUVM, SVA, and coverage](getting-started.md#understand-pyuvm-sva-and-coverage) | +| Qualify HDL and formal coverage | [Coverage qualification](coverage-qualification.md) | +| Qualify known faults and X/Z controls | [Qualification campaigns](qualification-campaigns.md) | +| Validate portable SDC and UPF intent | `make open-static-intent` | +| Run pinned containerized OpenROAD | `make OPENROAD_EXECUTION_MODE=container open-physical` | | Inspect machine-readable status | Module `reports//status.txt` | | Add a methodology flow | [Methodology development](development.md#adding-a-flow) | @@ -45,10 +70,19 @@ most important implementation entry points are: - `config/flows.mk` for canonical flow IDs, default states, and dependencies - `config/tools.mk` for executable defaults and the shared tool cache - `mk/module.mk` for public Make targets and the dependency graph +- `mk/project.mk` for multi-module selection and configuration loading +- `ci/module_manifest.py` for module registry validation and matrix generation +- `ci/parameter_profiles.py` for parameter translation and profile evidence +- `ci/release_manifest.py` for release evidence generation and validation +- `ci/static_intent.py` for non-executing SDC and UPF command capture +- `ci/openroad_evidence.py` for physical artifact and metric qualification - `ci/run_flow.sh` for execution eligibility and status recording - `ci/*_quality_gate.sh` for acceptance policy - `flows//` for tool adapters - `tests/fixture-module/` for repository-level integration coverage +- `tests/fixture-multi-module/` for concurrent project orchestration coverage +- `tests/fixture-parameter-profiles/` for parameter-matrix qualification +- `tests/test_release_manifest.sh` for manifest acceptance-policy coverage Please update the relevant document in the same change whenever one of these contracts changes. diff --git a/docs/architecture.md b/docs/architecture.md index 7aaf677..de51fff 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -50,6 +50,7 @@ mosaic-flow/ | |-- tool-versions.env Pinned downloadable tool versions and checksums | `-- tools.mk Executable names, cache roots, and PATH setup |-- docs/ User and maintainer documentation +|-- schemas/ Versioned machine-readable evidence contracts |-- flows/ | |-- common/ Environment validation and status helper | |-- / Shared shell and Tcl adapters @@ -57,6 +58,11 @@ mosaic-flow/ |-- mk/module.mk Make API imported by module repositories |-- tests/ | |-- fixture-module/ Minimal independent consumer used by CI +| |-- fixture-multi-module/ Multi-module selection and concurrency fixture +| |-- fixture-parameter-profiles/ Parameter elaboration matrix fixture +| |-- test_release_manifest.sh Release acceptance-policy fixture +| |-- test_coverage_qualification.sh Coverage policy positive and negative fixtures +| |-- test_qualification_campaigns.sh Negative and four-state gate fixtures | `-- test_quality_gate.sh Configuration and quality-gate unit tests |-- README.md Repository overview `-- VERSION Methodology semantic version @@ -121,6 +127,12 @@ make FLOW_ROOT=/path/to/mosaic-flow open-source The module's recorded submodule revision remains the release authority. +For a manifest-based repository, the root Makefile imports `mk/project.mk`. +That bootstrap validates and loads `config/modules/.mk`, selects the +matching flow policy, derives module-qualified report and work roots, and then +imports the same `config/tools.mk` and `mk/module.mk` API. See +[Multi-module projects](multi-module-projects.md). + ## Execution lifecycle Every flow target follows the same control path: @@ -134,6 +146,8 @@ Every flow target follows the same control path: 7. The adapter writes logs and a final status below `reports//`. 8. An aggregate gate verifies that every required flow has the exact expected status for the resolved project policy. +9. The release-manifest target validates and indexes canonical and supplemental + statuses, tool versions, inputs, and compact evidence. Make prerequisites prevent normal out-of-order execution. The report check is a second boundary that protects direct script invocation, stale build graphs, and diff --git a/docs/configuration.md b/docs/configuration.md index d978582..aee1ba6 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -11,6 +11,16 @@ | Module `config/flows.mk` | Module | Enabled and disabled flows, dependency overrides | | Module `flows//...` | Module | Tool-specific design intent and waivers | +Multi-module repositories replace the two module configuration rows with +`config/modules.json`, `config/modules/.mk`, and +`config/modules/-flows.mk`. The complete contract is documented in +[Multi-module projects](multi-module-projects.md). + +A parameterized module may add `config/parameter-profiles.json`. Multi-module +repositories use `config/parameter-profiles/.json`. See +[Parameter-profile qualification](parameter-profiles.md) for the schema and +execution contract. + ## Precedence and override rules Shared flow defaults use `?=`. The module's `config/flows.mk` is included after @@ -95,10 +105,16 @@ The default dependencies represent direct artifact consumption: | Flow | Default dependencies | Reason | | --- | --- | --- | | `eqy_equivalence` | `yosys_synthesis` | EQY reads the Yosys netlist | +| `coverage_qualification` | `verilator_sim` | Qualification reads normal simulation coverage by default | +| `negative_qualification` | None | Cases own their compile and run commands | +| `four_state_qualification` | None | Cases compile independently with Icarus | +| `static_intent` | None | The validator reads module-owned SDC, UPF, and expectations directly | | `synopsys_primetime` | `synopsys_synthesis` | PrimeTime reads the synthesis DDC and SDC | | `synopsys_primepower` | `vcs_sim synopsys_synthesis` | PrimePower reads SAIF activity plus synthesis DDC and SDC | -All other default lists are empty. A project may add policy dependencies even +All other default lists are empty. Select `pyuvm_open_source` as the coverage +dependency when qualifying PyUVM evidence, or clear the list for a dedicated +coverage run. A project may add policy dependencies even when no file is directly consumed. For example: ```make @@ -160,9 +176,26 @@ The shared environment adapter requires these variables for every tool adapter: | `PROPERTY_FILELIST` | Shared SystemVerilog property and sequence dependencies | | `ASSERTION_FILELIST` | Shared SystemVerilog assertion and bind file list | | `COVERAGE_FILELIST` | Shared SystemVerilog coverage model and bind file list | +| `COVERAGE_QUALIFICATION_POLICY` | Versioned JSON policy for HDL and formal coverage qualification | +| `COVERAGE_QUALIFICATION_SOURCE` | Existing HDL evidence source or `dedicated` rerun | +| `QUALIFICATION_CAMPAIGN_MANIFEST` | Versioned negative-test and four-state campaign declarations | +| `STATIC_INTENT_CONFIG` | Versioned SDC and UPF expectation declarations | | `CONSTRAINT_DIR` | Directory containing synthesis `timing.sdc` | | `REPORT_DIR` | Root for persistent, reviewable results | | `WORK_DIR` | Root for disposable tool databases and generated netlists | +| `PARAMETER_PROFILE_MANIFEST` | Optional module-owned profile manifest path | +| `PROFILE` | Selected named profile for one invocation | +| `PROFILE_TARGET` | Target dispatched by `all-profiles`, default `open-source` | +| `PROFILE_JOBS` | Maximum concurrent profile jobs, default `JOBS` or `0` | + +When `PROFILE` is selected, the flow exports `PROFILE_PARAMETERS_JSON`, +`PROFILE_APPLICABLE_FLOWS`, and `MOSAIC_PROFILE_ACTIVE` to adapters. These are +methodology implementation variables and should not be assigned by consumers. +The selected name is appended to `REPORT_DIR` and `WORK_DIR` after module +selection. + +When `mk/project.mk` selects a manifest entry, `REPORT_DIR` and `WORK_DIR` +default to `reports/` and `work/`. Flow-specific inputs are required when their flow is enabled: @@ -175,6 +208,8 @@ Flow-specific inputs are required when their flow is enabled: | `FORMAL_COVER_CONFIG` | SymbiYosys cover reachability task | | `EQUIVALENCE_CONFIG` | EQY | | `OPENROAD_CONFIG` | OpenROAD Flow Scripts | +| `OPENROAD_CONSTRAINT_FILE` | OpenROAD Flow Scripts and physical evidence | +| `OPENROAD_EVIDENCE_POLICY` | OpenROAD physical evidence qualification | | `SYNTHESIS_CONSTRAINT_FILE` | Module convention for synthesis SDC | | `CDC_CONFIG` | VC CDC or SpyGlass CDC adapter | | `DFT_CONFIG` | SpyGlass DFT adapter | @@ -194,6 +229,42 @@ Flow-specific inputs are required when their flow is enabled: | `PYUVM_RUN_ARGS` | Additional shell-parsed simulator run arguments | | `PYUVM_PLUSARGS` | Additional shell-parsed HDL plusargs | | `SIM_COVERAGE` | Enable native coverage in normal simulation | +| `COVERAGE_QUALIFICATION_TOOL` | Shared coverage policy validator, normally not overridden | +| `QUALIFICATION_CAMPAIGN_TOOL` | Shared campaign validator and runner, normally not overridden | +| `STATIC_INTENT_TOOL` | Shared non-executing SDC and UPF validator, normally not overridden | +| `IVERILOG_CMD` | Icarus compiler used for four-state qualification | +| `VVP_CMD` | Icarus runtime used for four-state qualification | + +## Release evidence variables + +The shared `release-manifest` target derives its standard input set from the +design, flow, filelist, constraint, waiver, formal, physical, and power-intent +variables above. These variables extend or qualify the release record: + +| Variable | Default | Purpose | +| --- | --- | --- | +| `RELEASE_MODULE_NAME` | `MODULE` or `DESIGN_TOP` | Stable module identity | +| `MODULE_REVISION` | Empty | Explicit 40-digit module commit, required in CI | +| `METHODOLOGY_REVISION` | Empty | Explicit 40-digit `mosaic-flow` commit, required in CI | +| `RELEASE_EXECUTION_CONTEXT` | `native` | Context label and output namespace | +| `RELEASE_ALLOW_DIRTY` | `disabled` | Permit and record a dirty tree for local diagnostics | +| `RELEASE_MODULE_DIRTY` | Empty | Explicit packaged-module dirty-state attestation | +| `RELEASE_METHODOLOGY_DIRTY` | Empty | Explicit packaged-methodology dirty-state attestation | +| `RELEASE_TECHNOLOGY` | `technology-independent` | Technology context name | +| `RELEASE_TECHNOLOGY_METADATA_JSON` | `{}` | PDK, library, corner, or technology details | +| `RELEASE_METADATA_JSON` | `{}` | Deterministic module-owned annotations | +| `RELEASE_EXECUTION_METADATA_JSON` | `{}` | Volatile runner or container annotations | +| `RELEASE_SUPPLEMENTAL_GATES` | Empty | Required module-owned status IDs | +| `RELEASE_ADDITIONAL_INPUTS` | Empty | Extra required files or directories to hash | +| `RELEASE_ADDITIONAL_EVIDENCE` | Empty | Extra generated files to index and hash | +| `RELEASE_ADDITIONAL_TOOLS_JSON` | `[]` | Extra tool version commands and associated flows | +| `RELEASE_COVERAGE_EVIDENCE_JSON` | `[]` | Extra coverage kind, producer, and path records | +| `RELEASE_MANIFEST_DIR` | `REPORT_DIR/release_manifest/` | Manifest output directory | + +`RELEASE_INPUT_FILES` and `RELEASE_FILELISTS` are exported derived lists used +by the generator. Consumers should extend `RELEASE_ADDITIONAL_INPUTS` instead +of replacing these implementation variables. The JSON formats and release +policy are documented in [Release evidence](release-evidence.md). PyUVM is opt-in. A module enables its portable and commercial policies independently: @@ -257,6 +328,12 @@ When `COVERAGE_FILELIST` is nonempty, `FORMAL_COVER_CONFIG` is required and defaults to `enabled`. Verilator exports `coverage.dat` and `coverage.info`, while VCS retains its native `coverage.vdb` database. +Enable `FLOW_coverage_qualification` to turn this collected evidence into a +policy decision. Configure its source and matching dependency together, then +declare thresholds, named coverpoints, exclusions, and optional formal +reachability in `COVERAGE_QUALIFICATION_POLICY`. See +[Coverage qualification](coverage-qualification.md) for the complete contract. + The current Design Compiler adapter reads `$(CONSTRAINT_DIR)/timing.sdc`. Keep `SYNTHESIS_CONSTRAINT_FILE` consistent with that path until the adapter is changed to consume the variable directly. @@ -269,7 +346,16 @@ that path until the adapter is changed to consume the variable directly. | `TARGET_LIBRARY` | Site or project target libraries when used by setup Tcl | | `LINK_LIBRARY` | Site or project link libraries when used by setup Tcl | | `OPERATING_CONDITION` | Requested timing or power corner when used by setup Tcl | -| `OPENROAD_FLOW_ROOT` | Checkout root of OpenROAD Flow Scripts | +| `OPENROAD_EXECUTION_MODE` | `local` checkout or pinned `container` execution | +| `OPENROAD_FLOW_ROOT` | Checkout root of OpenROAD Flow Scripts in local mode | +| `OPENROAD_CONTAINER_RUNTIME` | OCI runtime command, `docker` by default | +| `OPENROAD_ORFS_IMAGE` | Immutable `image@sha256:` ORFS reference | +| `OPENROAD_PLATFORM` | Selected ORFS platform, `nangate45` by default | +| `OPENROAD_FLOW_VARIANT` | Stable ORFS output variant, `base` by default | +| `OPENROAD_DESIGN_NAME` | Design nickname, `DESIGN_TOP` by default | +| `OPENROAD_CONFIG` | Module-owned ORFS Make configuration | +| `OPENROAD_CONSTRAINT_FILE` | Exact module-owned SDC supplied to ORFS | +| `OPENROAD_EVIDENCE_POLICY` | Module-owned artifact and metric policy | The shared Tcl currently sources `TECH_SETUP_TCL` when it is nonempty. The other technology variables are exported for the site setup to consume. Their exact @@ -308,9 +394,27 @@ make synopsys-synth | `PRIMETIME_BIN` | `pt_shell` | PrimeTime | | `PRIMEPOWER_BIN` | `pt_shell` | PrimePower | -The current OpenROAD wrapper invokes the OpenROAD Flow Scripts Makefile. It -uses `OPENROAD_FLOW_ROOT` and `OPENROAD_CONFIG` rather than invoking -`OPENROAD_CMD` directly. +The OpenROAD wrapper invokes the ORFS Makefile locally or through the immutable +container pin. `OPENROAD_CMD` remains reserved for direct integrations. See +[Containerized OpenROAD](openroad.md) for execution and evidence details. + +`VERIBLE_FORMAT_ARGS` provides whitespace-separated formatter options such as +`--indentation_spaces=4`. `VERIBLE_FORMAT_PATHS` selects the module-owned files +or directories checked by the formatting flow and defaults to `rtl verif`. + +## Multi-module variables + +| Variable | Default | Purpose | +| --- | --- | --- | +| `MODULE_MANIFEST` | `$(MODULE_ROOT)/config/modules.json` | Versioned module registry | +| `MODULE` | Empty | Selected manifest entry for one flow invocation | +| `TARGET` | `open-source` | Target dispatched by `all-modules` | +| `MODULE_JOBS` | `JOBS` or `0` | Maximum concurrent module invocations; zero means unbounded | +| `MODULE_DESIGN_CONFIG` | `config/modules/.mk` | Selected design profile | +| `MODULE_FLOW_CONFIG` | `config/modules/-flows.mk` | Selected flow policy | + +Use `mk/project.mk` to activate these variables. The legacy single-module +import remains supported without a manifest. ## Open-source tool cache diff --git a/docs/coverage-qualification.md b/docs/coverage-qualification.md new file mode 100644 index 0000000..4d9bb31 --- /dev/null +++ b/docs/coverage-qualification.md @@ -0,0 +1,163 @@ +# Coverage qualification + +## Purpose + +`make open-coverage` converts collected HDL coverage into an explicit release +decision. The module declares goals and reviewed exclusions in +`config/coverage-policy.json`. `mosaic-flow` validates that policy, normalizes +the simulator evidence, applies the requirements, and writes a deterministic +`reports/coverage_qualification/summary.json`. + +The flow is optional and disabled by default. Enable it in `config/flows.mk`: + +```make +FLOW_coverage_qualification := enabled +FLOW_DEPENDENCIES_coverage_qualification := verilator_sim +``` + +Point `COVERAGE_QUALIFICATION_POLICY` at a different module-owned path only when +the repository layout requires it. The policy is included in release input +hashes whenever the flow is enabled. + +## Coverage layers + +The verification layers remain separate because they answer different +questions: + +| Layer | Meaning | Qualification behavior | +| --- | --- | --- | +| Assertions | Required temporal behavior checked during execution | Failure remains a simulation or formal failure | +| Named SVA coverpoints | Whether important temporal scenarios occurred | Minimum hits are checked by name | +| Native HDL line, branch, toggle, and user coverage | Structural activity measured by the HDL simulator | Independent percentage thresholds are checked | +| Formal cover | Whether declared cover statements are reachable under assumptions | Required SymbiYosys cover task must return `PASS` | +| PyUVM functional coverage | Python transaction-level model | Preserved as separate evidence and never merged into HDL percentages | + +PyUVM does not invoke SVA. The PyUVM adapter compiles the same property, +assertion, and coverage filelists as the normal HDL testbench. The simulator +evaluates those constructs while Python drives the DUT. Either run can therefore +produce compatible native HDL evidence for this gate. + +## Policy format + +The policy follows `schemas/coverage-policy-v1.schema.json`. The implementation +also performs semantic validation and rejects unknown fields, duplicate named +requirements, invalid ranges, missing exclusion sources, and stale exclusions. + +```json +{ + "schema": "mosaic-coverage-policy-v1", + "scope": { + "include": ["rtl/*.sv", "verif/coverage/*.sv"], + "exclude": [] + }, + "thresholds": { + "line": 95, + "branch": 90, + "toggle": 80, + "user": 100 + }, + "coverpoints": [ + {"name": "accepted_transaction", "minimum_hits": 2} + ], + "exclusions": [ + { + "source": "rtl/example.sv", + "metric": "toggle", + "reason": "Static strap is fixed by the supported integration mode.", + "owner": "module-maintainers", + "scope": {"line_start": 27, "name": "test_mode_i:0->1"} + } + ], + "formal": {"required": true} +} +``` + +Each threshold is optional and independent. An omitted metric is reported but +does not gate the result. A declared threshold fails when no counters of that +metric remain in scope. `scope.include` and `scope.exclude` use module-relative +shell globs and should keep structural thresholds focused on the intended RTL. + +Named coverpoints use the SystemVerilog statement label emitted as the native +`user` counter name. Every declaration requires at least one matching native +counter and its aggregate hit count must meet `minimum_hits`. + +Every exclusion records a real module-relative source, one metric, a technical +reason, an accountable owner, and a narrow scope. `line_start`, optional +`line_end`, and optional exact counter `name` can be combined. An exclusion that +matches no in-scope collected counter is stale and fails qualification. Explicit +named-coverpoint requirements remain enforced even if a user counter is +excluded from the percentage calculation. + +## Evidence source + +Normal Verilator simulation is the default: + +```make +COVERAGE_QUALIFICATION_SOURCE := verilator_sim +FLOW_DEPENDENCIES_coverage_qualification := verilator_sim +``` + +To qualify the HDL coverage generated while PyUVM drives the DUT: + +```make +COVERAGE_QUALIFICATION_SOURCE := pyuvm_open_source +FLOW_DEPENDENCIES_coverage_qualification := pyuvm_open_source +``` + +The selected source must have `PASS` status and must provide `coverage.dat`, +`coverage.info`, or both. `coverage.dat` supplies typed Verilator counters and +is required for named user coverpoints. LCOV supplies portable line records and +is a fallback for branch and toggle counters when typed native records are not +available. The parser uses record metadata rather than version-specific page +names. LCOV transition names containing `->` are treated as toggles instead of +control-flow branches. + +Use a dedicated rerun when qualification needs different compile options: + +```make +COVERAGE_QUALIFICATION_SOURCE := dedicated +FLOW_DEPENDENCIES_coverage_qualification := +``` + +The adapter runs the existing testbench through the shared Verilator simulation +adapter with coverage forced on and stores source evidence below +`reports/coverage_qualification/dedicated/`. + +## Formal reachability + +When `formal.required` is true, `FORMAL_COVER_CONFIG` is mandatory. The adapter +runs that configuration in an isolated qualification work directory. SymbiYosys +cover mode returns `PASS` only when its generated cover tasks meet the configured +reachability goal, so any unreachable statement fails the combined result. A +passing run must also report at least one reached cover statement. + +The summary records formal status separately from simulation metrics. It does +not merge bounded formal reachability into simulator hit counts. Depth, engines, +assumptions, and completeness remain module-owned review concerns. + +## Reports and release evidence + +The canonical status is one of `PASS`, `FAIL`, or `SKIP`. Enabled malformed +policies, missing counters, stale exclusions, unmet thresholds, missing named +points, and failed formal reachability produce `FAIL`. Explicitly disabling the +flow through normal flow policy produces `SKIP`. + +`summary.json` records effective totals, hits, percentages, thresholds, +exclusion match counts, named coverpoint hits, formal status, and every failure. +The release manifest automatically indexes this summary as +`coverage_qualification_summary`. Because the flow is canonical, an enabled +release also requires its `status.txt` to be `PASS`. + +## Simulator limitations + +- Verilator native coverage is the qualified open-source source for typed user + and toggle counters. Other native formats require a future adapter. +- Some Verilator LCOV releases represent toggles as `BRDA` records. The native + type takes precedence when available. +- LCOV alone does not reliably preserve named SVA user coverpoints. Policies + declaring `coverpoints` therefore need compatible native evidence. +- An Icarus PyUVM run does not currently produce compatible HDL coverage and + cannot feed this gate. +- Commercial coverage databases and cross-simulator database merging are out of + scope. They may be retained as additional release evidence without replacing + this open-source gate. diff --git a/docs/development.md b/docs/development.md index 11e36ef..b0ad0c2 100644 --- a/docs/development.md +++ b/docs/development.md @@ -38,6 +38,8 @@ Complete portable fixture integration: ```sh make -C tests/fixture-module FLOW_ROOT="$PWD" clean open-source +tests/test_release_manifest.sh +tests/test_coverage_qualification.sh ``` Review `tests/fixture-module/reports/` when integration fails. Do not use the @@ -182,6 +184,50 @@ Add a focused regression for each bug fixed in orchestration or gate logic. repositories. It uses `tests/fixture-module/` and qualifies both shell-level policy and the complete portable EDA path. +`tests/fixture-multi-module/` separately qualifies registry validation, +selection, fallback resolution, concurrent execution, and output isolation. +Update `tests/test_multi_module.sh` whenever the manifest or project bootstrap +contract changes. + +`tests/fixture-parameter-profiles/` qualifies boundary widths, feature toggles, +backend parameter translation, profile-local formal and equivalence inputs, +concurrent execution, and aggregate evidence. Update +`tests/test_parameter_profiles.sh` whenever the profile schema changes. + +`tests/test_release_manifest.sh` builds a temporary consumer repository and +qualifies manifest generation, deterministic comparison, input hashing, +approved skips, supplemental gates, dirty-tree policy, and negative status and +revision handling. The workflow runs it both natively and in the pinned +container job. Extend this fixture whenever the release schema or policy +changes. + +`tests/check_fixture_release_manifest.sh` checks the complete portable fixture +after real tool execution. It requires every canonical flow decision, portable +tool identity, representative design input, both coverage classes, and the +human-readable summary to be present in the generated release artifact. + +`tests/test_coverage_qualification.sh` exercises complete and deficient HDL +evidence, malformed requirements, valid and stale exclusions, missing named +coverpoints, formal reachability failure, and explicit flow statuses. The +workflow runs this fixture both natively and in the pinned container job. +`tests/test_coverage_unreachable_formal.sh` additionally runs a deliberately +unreachable cover statement through SymbiYosys in the EDA integration job and +requires the canonical flow to record `FAIL` before restoring passing evidence. + +`tests/test_qualification_campaigns.sh` exercises detected and escaped +mutations, expected inequivalence, unrelated tool failure, detected unknown +controls, a broken monitor, failed controls, and explicit disabled statuses. +The complete fixture additionally compiles and runs an X/Z campaign with the +pinned Icarus simulator. Both methodology fixtures run in native CI, while the +tool-independent positive and negative cases also run in the pinned container. + +`tests/test_static_intent.sh` covers sequential, combinational, generated-clock, +reset-synchronizer, always-on, isolation, level-shifting, and retention intent. +Its negative cases require actionable failures for missing constraints, broad +exceptions, duplicates, conflicts, profile mismatches, incomplete power states, +forbidden strategies, and unsupported commands. The same test runs natively and +inside the pinned container without an EDA license. + When editing workflow YAML or shell scripts, run `ci/check_flow_quality.sh`. It uses pinned ShellCheck and actionlint versions installed by `ci/install_ci_tools.sh`. diff --git a/docs/flows.md b/docs/flows.md index 126aaef..92f6001 100644 --- a/docs/flows.md +++ b/docs/flows.md @@ -10,6 +10,13 @@ module repository. All target paths below are relative to the module root unless identified as shared methodology paths. +When `PROFILE=` is selected, supported adapters elaborate the canonical +parameter map and place all paths below that profile's report and work roots. +Verible remains a source-only check. Formal and EQY adapters render temporary +profile-specific configuration files without modifying module-owned inputs. +See [Parameter-profile qualification](parameter-profiles.md) for the complete +backend mapping. + ## Summary | Canonical ID | Make target | Tool | Gate | Default dependencies | @@ -23,6 +30,10 @@ shared methodology paths. | `eqy_equivalence` | `open-equivalence` | EQY | Open-source | `yosys_synthesis` | | `verilator_sim` | `open-sim` | Verilator | Open-source | None | | `pyuvm_open_source` | `open-pyuvm` | PyUVM, cocotb, Verilator or Icarus | Open-source | None | +| `coverage_qualification` | `open-coverage` | MOSAIC policy validator, Verilator, optional SymbiYosys | Open-source | `verilator_sim` | +| `negative_qualification` | `open-negative` | MOSAIC declarative campaign runner | Open-source | None | +| `four_state_qualification` | `open-four-state` | MOSAIC declarative campaign runner, Icarus | Open-source | None | +| `static_intent` | `open-static-intent` | MOSAIC SDC and UPF validator | Open-source | None | | `openroad` | `open-physical` | OpenROAD Flow Scripts | Optional | None | | `vcs_sim` | `synopsys-sim` | VCS | Commercial | None | | `pyuvm_commercial` | `commercial-pyuvm` | PyUVM, cocotb, VCS or Xcelium | Commercial | None | @@ -226,23 +237,99 @@ Verilator remains the portable default. More information: [PyUVM](https://github.com/pyuvm/pyuvm) and [cocotb simulator support](https://docs.cocotb.org/en/stable/simulator_support.html). +### Coverage qualification + +- **ID:** `coverage_qualification` +- **Target:** `make open-coverage` +- **Adapter:** [`flows/coverage/run.sh`](../flows/coverage/run.sh) +- **Inputs:** `COVERAGE_QUALIFICATION_POLICY`, native simulator coverage, and + optional `FORMAL_COVER_CONFIG` +- **Reports:** `reports/coverage_qualification/summary.json`, `status.txt`, and + an optional `formal.log` +- **Default dependency:** `verilator_sim` + +This optional gate applies independent line, branch, toggle, and user coverage +thresholds, minimum hits for named SystemVerilog coverpoints, and validated +reviewed exclusions. It can consume existing Verilator simulation or PyUVM HDL +coverage, or request a dedicated Verilator rerun. Its machine-readable summary +identifies every unmet requirement and is indexed by the release manifest. + +See [Coverage qualification](coverage-qualification.md) for the policy schema, +source selection, formal behavior, limitations, and migration examples. + +### Portable static intent + +- **ID:** `static_intent` +- **Target:** `make open-static-intent` +- **Adapter:** [`flows/static_intent/run.sh`](../flows/static_intent/run.sh) +- **Inputs:** `STATIC_INTENT_CONFIG` and its selected module-owned SDC and UPF +- **Reports:** `sdc-findings.json`, `upf-findings.json`, `summary.json`, and + `status.txt` below `reports/static_intent/` + +This optional gate captures a safe, documented Tcl subset without executing the +module-owned files. It checks timing policy profiles, constrained ports, +declared asynchronous exceptions, profile consistency, low-power resources, +power states, and required or forbidden strategies. Unsupported commands and +options are explicit failures. + +This flow is not STA, OpenSTA, PrimeTime, IEEE 1801, or VC LP signoff. See +[Portable SDC and UPF intent](static-intent.md) for the schema, supported command +subset, fixtures, and technology-signoff boundary. + +### Negative-test qualification + +- **ID:** `negative_qualification` +- **Target:** `make open-negative` +- **Adapter:** [`flows/qualification/run.sh`](../flows/qualification/run.sh) +- **Inputs:** `QUALIFICATION_CAMPAIGN_MANIFEST` and module-owned fault fixtures +- **Reports:** aggregate and per-case JSON, status, command, compile, and run evidence + +This optional gate proves that declared simulation mutations, assertion faults, +invalid parameters, and inequivalent candidates are detected for the expected +reason. Every fault references a passing nominal control. The runner rejects an +escaped fault and distinguishes unrelated infrastructure failures from expected +design-check failures. + +### Four-state qualification + +- **ID:** `four_state_qualification` +- **Target:** `make open-four-state` +- **Adapter:** [`flows/qualification/run.sh`](../flows/qualification/run.sh) +- **Simulator:** pinned Icarus and VVP from OSS CAD Suite +- **Inputs:** `QUALIFICATION_CAMPAIGN_MANIFEST` and module-owned four-state stimulus +- **Reports:** aggregate and per-case JSON, status, command, compile, and run evidence + +This optional gate injects declared X/Z values into control inputs and requires +a specific monitor diagnostic. A separate disabled-monitor control proves that +the illegal stimulus reaches the design without an unrelated failure. It does +not change the two-state Verilator simulation contract. + +See [Negative-test and four-state qualification](qualification-campaigns.md) +for the manifest schema, case semantics, evidence, and migration guidance. + ### OpenROAD physical implementation - **ID:** `openroad` - **Target:** `make open-physical` - **Adapter:** [`flows/openroad/run.sh`](../flows/openroad/run.sh) -- **Inputs:** `OPENROAD_FLOW_ROOT`, `OPENROAD_CONFIG`, PDK and platform collateral -- **Reports:** `reports/openroad/run.log`, `status.txt` -- **Work products:** managed by the selected OpenROAD Flow Scripts checkout +- **Inputs:** `OPENROAD_CONFIG`, `OPENROAD_CONSTRAINT_FILE`, + `OPENROAD_EVIDENCE_POLICY`, platform and PDK collateral +- **Reports:** `reports/openroad/run.log`, `evidence.json`, `status.txt` +- **Work products:** `work/openroad/{results,reports,logs,objects}////` This optional adapter invokes OpenROAD Flow Scripts with the module-owned design configuration. It is not part of `make open-source` because it requires a selected PDK, compatible libraries, LEF data, and physical constraints. -Set `OPENROAD_FLOW_ROOT` to a qualified OpenROAD Flow Scripts checkout. The -module configuration must select its platform, top, source files, SDC, and -physical targets. The exact physical result hierarchy is owned by OpenROAD Flow -Scripts rather than copied into the module's `work/openroad/` directory. +Set `OPENROAD_EXECUTION_MODE=local` and `OPENROAD_FLOW_ROOT` for a qualified +checkout, or select `container` to use the methodology's immutable ORFS image. +The module configuration selects its platform, top, source files, SDC, physical +targets, expected artifacts, parsed metrics, and acceptance thresholds. The +adapter isolates ORFS outputs below the selected module and profile work root, +runs a container with the invoking UID and GID, and hashes accepted outputs. + +See [Containerized OpenROAD](openroad.md) for the complete configuration, +policy, evidence, CI, and non-signoff contract. More information: [OpenROAD Flow](https://openroad-flow-scripts.readthedocs.io/en/latest/mainREADME.html) and its [configuration tutorial](https://openroad-flow-scripts.readthedocs.io/en/latest/tutorials/FlowTutorial.html). diff --git a/docs/getting-started.md b/docs/getting-started.md index 3970b83..b165c55 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -6,8 +6,10 @@ For the portable open-source flow, use: - Linux x86-64 for automatic binary installation - GNU Make +- GNU `xargs` from findutils for multi-module parallel execution - Bash - Git +- Python 3 - `curl`, `tar`, and `sha256sum` - Network access during the first tool installation @@ -52,6 +54,10 @@ include $(FLOW_ROOT)/mk/module.mk Keeping this file thin lets a methodology update change orchestration and tool versions by moving one Git pointer. +Repositories with multiple independently selectable RTL modules should import +`mk/project.mk` instead. See [Multi-module projects](multi-module-projects.md) +for the manifest, module profile, concurrent execution, and CI contracts. + ## Define the module contract Create `config/design.mk` with at least these values: @@ -76,6 +82,8 @@ export FORMAL_CONFIG := $(FLOW_CONFIG_ROOT)/symbiyosys/formal.sby export FORMAL_COVER_CONFIG := $(FLOW_CONFIG_ROOT)/symbiyosys/formal_cover.sby export EQUIVALENCE_CONFIG := $(FLOW_CONFIG_ROOT)/eqy/equivalence.eqy export OPENROAD_CONFIG := $(FLOW_CONFIG_ROOT)/openroad/config.mk +export OPENROAD_CONSTRAINT_FILE := $(FLOW_CONFIG_ROOT)/openroad/timing.sdc +export OPENROAD_EVIDENCE_POLICY := $(FLOW_CONFIG_ROOT)/openroad/evidence.json export SYNTHESIS_CONSTRAINT_FILE := $(FLOW_CONFIG_ROOT)/synthesis/timing.sdc export CDC_CONFIG := $(FLOW_CONFIG_ROOT)/cdc/constraints.tcl export DFT_CONFIG := $(FLOW_CONFIG_ROOT)/sg_dft/constraints.tcl @@ -95,6 +103,23 @@ not be portable. See [Configuration](configuration.md#design-and-path-variables) for optional technology variables and the exact meaning of each setting. +## Qualify parameterized elaborations + +Add `config/parameter-profiles.json` when materially different parameter values +need independently attributable evidence. Declare boundary values, nominal +settings, feature toggles, and the flows required for each profile. Then run: + +```sh +make profile-manifest-check +make profile-list +make clean all-profiles PROFILE_JOBS=4 +``` + +Once a manifest exists, select `PROFILE=` for individual flow targets. +The methodology applies the exact parameter set and writes isolated work and +reports below that profile name. The complete schema, evidence policy, and CI +examples are in [Parameter-profile qualification](parameter-profiles.md). + ## Understand PyUVM, SVA, and coverage PyUVM does not import or call SystemVerilog assertions from Python. PyUVM @@ -176,6 +201,7 @@ FLOW_symbiyosys_formal := enabled FLOW_eqy_equivalence := enabled FLOW_verilator_sim := enabled FLOW_pyuvm_open_source := disabled +FLOW_static_intent := disabled FLOW_openroad := disabled FLOW_vcs_sim := enabled @@ -205,6 +231,23 @@ Check the resolved policy before running tools: make flow-config-check ``` +## Generate release evidence + +After every enabled flow and module-owned supplemental gate has passed, create +one indexed qualification record: + +```sh +make \ + MODULE_REVISION="$(git rev-parse HEAD)" \ + METHODOLOGY_REVISION="$(git -C mosaic-flow rev-parse HEAD)" \ + release-manifest release-manifest-validate +``` + +The output is written below `reports/release_manifest/native/`. Configure +technology details, extra inputs, and supplemental gates in `config/design.mk`. +See [Release evidence](release-evidence.md) before connecting the target to CI +or a module release checklist. + ## Run the first portable checks List the available targets and prepare the pinned tool cache: diff --git a/docs/multi-module-projects.md b/docs/multi-module-projects.md new file mode 100644 index 0000000..7bbc86c --- /dev/null +++ b/docs/multi-module-projects.md @@ -0,0 +1,252 @@ +# Multi-module projects + +## Purpose + +A multi-module repository can qualify related RTL blocks independently while +sharing one pinned `mosaic-flow` checkout. Each registered module owns its design +configuration and flow policy. The methodology owns registry validation, +selection, concurrent dispatch, and output isolation. + +Single-module repositories do not need a manifest and may retain the original +import sequence. Adopt this interface only when one repository contains more +than one independently selectable design top. + +## Import the project API + +Use the project bootstrap instead of loading `config/design.mk` directly: + +```make +SHELL := /usr/bin/env bash + +export MODULE_ROOT := $(CURDIR) +export FLOW_ROOT ?= $(abspath $(MODULE_ROOT)/mosaic-flow) + +include $(FLOW_ROOT)/mk/project.mk +``` + +`mk/project.mk` detects `MODULE_MANIFEST`, loads the selected module profile, +then imports the normal tool and target APIs. Its default manifest path is +`config/modules.json`. + +## Declare the module registry + +The `mosaic-modules-v1` schema uses a nonempty `include` array. Every entry +requires a unique lowercase `name`. Additional JSON fields are preserved in the +generated CI matrix. + +```json +{ + "schema": "mosaic-modules-v1", + "include": [ + { + "name": "counter", + "artifact_suffix": "native" + }, + { + "name": "clock_gate", + "artifact_suffix": "native" + } + ] +} +``` + +For each name, the validator requires: + +```text +config/modules/.mk +config/modules/-flows.mk +``` + +Names accept lowercase letters, digits, and underscores, must begin with a +letter, and cannot contain path separators. Duplicate names, malformed JSON, +unknown selections, and missing configuration fail before a tool starts. + +## Configure one module + +The design profile exports the same variables as a single-module +`config/design.mk`. `REPORT_DIR` and `WORK_DIR` default to isolated paths and do +not need to be repeated. + +```make +export DESIGN_TOP := counter +export TB_TOP := $(DESIGN_TOP)_tb +export FORMAL_TOP := $(DESIGN_TOP)_formal +export DUT_INSTANCE := $(TB_TOP)/dut + +export RTL_FILELIST := $(call mosaic_resolve_filelist,rtl.f) +export TB_FILELIST := $(call mosaic_resolve_filelist,tb.f) +export FORMAL_CONFIG := $(call mosaic_resolve_flow_config,symbiyosys,formal.sby) +export CONSTRAINT_DIR := $(MODULE_ROOT)/flows/synthesis +``` + +The resolver functions implement these searches: + +```text +filelists/. +filelists/ + +flows//. +flows// +``` + +The first existing named input wins. The fallback path is returned when neither +path exists so the consuming adapter reports the missing required input. + +The matching `-flows.mk` file declares the complete `FLOW_` policy and +dependency overrides for that module. + +## Select and run modules + +Validate and query the registry with: + +```sh +make module-manifest-check +make module-list +make module-matrix +make module-profile-matrix +``` + +Run one module with any normal target: + +```sh +make MODULE=counter flow-config-check +make MODULE=counter clean open-source +``` + +A flow target without `MODULE` fails in a multi-module project. Administrative +targets do not require a selection. + +Run one target for every module with: + +```sh +make all-modules +make all-modules TARGET=open-formal MODULE_JOBS=4 +``` + +`TARGET` defaults to `open-source`. `MODULE_JOBS=0` allows GNU `xargs` to use all +available processors. A positive value bounds concurrency. The aggregate waits +for every invocation and returns nonzero if any module fails. Results from +modules that completed successfully remain available. + +Each selected module defaults to: + +```text +reports/// +work/// +``` + +If a selected module declares parameter profiles, the profile name is inserted +before the flow ID. `module-profile-matrix` emits the Cartesian registry defined +by each module's own manifest, while a module with no profile manifest emits one +`default` entry. See [Parameter-profile qualification](parameter-profiles.md). + +`make MODULE= clean` removes only that module's generated state. Running +`make clean` without a selection removes the complete project work and report +trees. + +## Configure formatting + +Profiles can set formatting policy without a wrapper command: + +```make +export VERIBLE_FORMAT_ARGS := --indentation_spaces=4 +export VERIBLE_FORMAT_PATHS := rtl/counter.sv verif/counter +``` + +Both variables are whitespace-separated. Paths are relative to `MODULE_ROOT` +and may name files or directories. Narrow `VERIBLE_FORMAT_PATHS` in flat +multi-module repositories so one module job does not recheck unrelated sources. + +## GitHub Actions matrix + +Generate the matrix from the validated methodology API: + +```yaml +jobs: + module-matrix: + runs-on: ubuntu-24.04 + outputs: + matrix: ${{ steps.modules.outputs.matrix }} + flow_revision: ${{ steps.flow.outputs.revision }} + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Read pinned mosaic-flow revision + id: flow + run: | + revision="$(git ls-files --stage mosaic-flow | awk '$1 == "160000" {print $2}')" + test "${#revision}" -eq 40 + echo "revision=${revision}" >> "${GITHUB_OUTPUT}" + - uses: actions/checkout@v4 + with: + repository: ECASLab/mosaic-flow + ref: ${{ steps.flow.outputs.revision }} + path: mosaic-flow + persist-credentials: false + - name: Generate matrix + id: modules + run: echo "matrix=$(make module-profile-matrix)" >> "${GITHUB_OUTPUT}" + + rtl-checks: + name: ${{ matrix.job_name }} / native + needs: module-matrix + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.module-matrix.outputs.matrix) }} + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/checkout@v4 + with: + repository: ECASLab/mosaic-flow + ref: ${{ needs.module-matrix.outputs.flow_revision }} + path: mosaic-flow + persist-credentials: false + - name: Verify pinned mosaic-flow revision + run: | + actual="$(git -C mosaic-flow rev-parse HEAD)" + test "${actual}" = "${{ needs.module-matrix.outputs.flow_revision }}" + - uses: actions/cache@v4 + with: + path: ~/.cache/mosaic + key: mosaic-${{ runner.os }}-${{ matrix.module }}-${{ matrix.profile }}-${{ needs.module-matrix.outputs.flow_revision }} + - run: make MODULE="${{ matrix.module }}" PROFILE="${{ matrix.profile }}" clean open-source + - uses: actions/upload-artifact@v4 + if: always() + with: + name: ${{ matrix.job_name }}-reports + path: reports/${{ matrix.module }}/ + if-no-files-found: error +``` + +Container jobs use the same matrix and pass `MODULE=${{ matrix.module }}` and +`PROFILE=${{ matrix.profile }}` to the container command. Give every job a +module/profile-qualified cache scope, image tag, artifact name, report path, and +diagnostic log. In both native and container jobs, read the expected methodology +revision from the parent gitlink and verify the checked-out or embedded revision +before running the flow. + +## Migrate an existing repository + +For a repository with consumer-owned orchestration such as `mosaic-common`: + +1. Move the module list to `config/modules.json` and add the + `mosaic-modules-v1` schema field. +2. Replace the custom root imports and `all-modules` recipe with + `include $(FLOW_ROOT)/mk/project.mk`. +3. Keep module profiles under `config/modules/` and remove duplicated profile + existence checks. +4. Replace local resolver functions with `mosaic_resolve_filelist` and + `mosaic_resolve_flow_config`. +5. Remove explicit `REPORT_DIR` and `WORK_DIR` assignments when the standard + isolated paths are sufficient. +6. Replace formatter wrapper scripts with `VERIBLE_FORMAT_ARGS` and + `VERIBLE_FORMAT_PATHS` where possible. +7. Generate the CI matrix through `make module-profile-matrix` when any module + declares profiles, and retain exact gitlink verification in every job. + +Run `make module-manifest-check` before removing the old orchestration, then +compare one native and one containerized result per module. diff --git a/docs/openroad.md b/docs/openroad.md new file mode 100644 index 0000000..23a3a4e --- /dev/null +++ b/docs/openroad.md @@ -0,0 +1,159 @@ +# Containerized OpenROAD Flow Scripts + +## Purpose and boundary + +The `openroad` adapter runs module-owned RTL and constraints through OpenROAD +Flow Scripts (ORFS). It supports a qualified local checkout and a pinned OCI +container while applying the same declarative physical evidence policy. + +The public Nangate45 fixture demonstrates reproducible exploratory synthesis, +placement, clock-tree synthesis, routing, and output generation. A `PASS` result +is not foundry signoff. It does not replace proprietary PDK validation, +extraction, signal-integrity analysis, physical verification, or signoff timing +and power tools. + +## Module-owned inputs + +Declare the physical configuration in the module's `config/design.mk`: + +```make +export OPENROAD_CONFIG := $(CURDIR)/config/openroad.mk +export OPENROAD_CONSTRAINT_FILE := $(CURDIR)/constraints/timing.sdc +export OPENROAD_EVIDENCE_POLICY := $(CURDIR)/config/openroad-evidence.json +export OPENROAD_PLATFORM := nangate45 +export OPENROAD_FLOW_VARIANT := release +export OPENROAD_DESIGN_NAME := $(DESIGN_TOP) +``` + +Both `OPENROAD_CONFIG` and `OPENROAD_CONSTRAINT_FILE` must resolve below +`MODULE_ROOT`. The adapter mounts the module read-only in container mode and +passes the selected SDC to ORFS explicitly, so the file hashed in the evidence +is the file used by implementation. + +An ORFS configuration remains module-owned. For example: + +```make +export DESIGN_NAME = my_module +export PLATFORM = $(OPENROAD_PLATFORM) +export VERILOG_FILES = $(REPO_ROOT)/rtl/my_module.sv +export SDC_FILE = $(REPO_ROOT)/constraints/timing.sdc +export DIE_AREA = 0 0 60 60 +export CORE_AREA = 5 5 55 55 +``` + +## Physical evidence policy + +The policy uses the versioned +[`mosaic-openroad-evidence-policy-v1`](../schemas/openroad-evidence-policy-v1.schema.json) +contract. Artifact paths are relative to the selected ORFS result, report, log, +or object directory. Every required artifact must exist and be nonempty. + +```json +{ + "schema": "mosaic-openroad-evidence-policy-v1", + "artifacts": [ + {"name": "final_def", "directory": "results", "path": "6_final.def"}, + {"name": "final_gds", "directory": "results", "path": "6_final.gds"}, + {"name": "final_odb", "directory": "results", "path": "6_final.odb"}, + {"name": "final_sdc", "directory": "results", "path": "6_final.sdc"}, + {"name": "final_netlist", "directory": "results", "path": "6_final.v"} + ], + "metrics": [ + { + "name": "setup_violations", + "directory": "reports", + "path": "6_finish.rpt", + "pattern": "setup violation count[ \\t]+(?P[0-9]+)", + "maximum": 0 + }, + { + "name": "routing_violations", + "directory": "logs", + "path": "5_2_route.log", + "pattern": "Number of violations = (?P[0-9]+)", + "match": "last", + "maximum": 0 + } + ] +} +``` + +Each metric regular expression must contain exactly one named integer group +called `value`. `match` defaults to `only` and may select `first` or `last` when +a tool logs an iterative metric multiple times. A metric may declare `minimum`, +`maximum`, or both. Modules normally declare setup, hold, slew, fanout, +capacitance, and routing violation counts, but their report paths and thresholds +remain policy rather than shared script constants. + +## Container execution + +Container mode defaults to the immutable ORFS image digest in +`config/tool-versions.env`: + +```sh +make OPENROAD_EXECUTION_MODE=container open-physical +``` + +`OPENROAD_CONTAINER_RUNTIME` defaults to `docker` and may select a compatible +runtime. `OPENROAD_ORFS_IMAGE` may override the image only with an +`image@sha256:` reference. A missing runtime or floating image fails +before implementation. + +The container runs with the invoking UID and GID. `MODULE_ROOT` is mounted +read-only at `/workspace`, while the profile-qualified `WORK_DIR/openroad` is +mounted read-write at `/orfs-work`. ORFS places results, reports, logs, and +objects below that root using platform, design, and variant components. Module +selection and parameter-profile selection already qualify `WORK_DIR`, so +concurrent jobs do not share physical databases. + +CI can prepare the pinned image and retain its layers with: + +```sh +ci/cache_openroad_image.sh +``` + +The cache archive name is derived from the immutable image digest. Changing the +methodology pin therefore creates a new cache entry instead of silently reusing +a floating ORFS version. + +## Local execution + +Local mode preserves the checkout-based workflow: + +```sh +make \ + OPENROAD_EXECUTION_MODE=local \ + OPENROAD_FLOW_ROOT="$HOME/tools/OpenROAD-flow-scripts" \ + open-physical +``` + +The checkout must contain `flow/Makefile`. When it is a Git repository, its +resolved revision is recorded in evidence. Local ORFS dependencies and platform +collateral remain the developer or site administrator's responsibility. + +## Outputs and release evidence + +The adapter writes: + +```text +reports/openroad/ +|-- evidence.json +|-- run.log +`-- status.txt + +work/openroad/ +|-- logs//// +|-- objects//// +|-- reports//// +`-- results//// +``` + +`evidence.json` records execution mode, platform, design, variant, local ORFS +revision or container identity, runtime version, configuration and SDC hashes, +artifact sizes and hashes, parsed metrics, thresholds, and failures. Container +evidence retains both the configured immutable reference and the resolved local +image ID. + +When `openroad` is required, `make release-manifest` automatically hashes the +policy as an input and indexes `reports/openroad/evidence.json`. Missing physical +evidence then fails release-manifest generation. diff --git a/docs/parameter-profiles.md b/docs/parameter-profiles.md new file mode 100644 index 0000000..26ecba4 --- /dev/null +++ b/docs/parameter-profiles.md @@ -0,0 +1,259 @@ +# Parameter-profile qualification + +## Purpose + +A parameter profile names one representative RTL elaboration and the evidence +required to qualify it. Profiles let a module cover boundary widths, optional +features, reset modes, polarity choices, and implementation branches without +mixing their generated artifacts. + +Profiles are module-owned. `mosaic-flow` validates and dispatches the declared +set, translates parameters for each backend, isolates results, and reports an +aggregate status. It does not discover legal combinations automatically. + +Repositories without a profile manifest keep their existing unqualified +behavior. `make profile-list` reports `default` in that case. + +## Declare profiles + +The default manifest path for a single-module repository is: + +```text +config/parameter-profiles.json +``` + +For a module named `counter` in a multi-module repository, it is: + +```text +config/parameter-profiles/counter.json +``` + +Override `PARAMETER_PROFILE_MANIFEST` in module configuration only when another +location is necessary. + +The `mosaic-parameter-profiles-v1` schema contains an ordered, nonempty +`include` array: + +```json +{ + "schema": "mosaic-parameter-profiles-v1", + "include": [ + { + "name": "width_min", + "parameters": { + "SATURATE": false, + "WIDTH": 1 + }, + "flows": [ + "slang_elaboration", + "verilator_lint", + "yosys_synthesis", + "symbiyosys_formal", + "eqy_equivalence", + "verilator_sim" + ] + }, + { + "name": "saturating", + "parameters": { + "SATURATE": true, + "WIDTH": 8 + }, + "flows": [ + "slang_elaboration", + "verilator_lint", + "yosys_synthesis", + "symbiyosys_formal", + "eqy_equivalence", + "verilator_sim" + ], + "tops": { + "design": "counter", + "formal": "counter_formal", + "pyuvm": "counter", + "testbench": "counter_tb" + } + } + ] +} +``` + +Each profile has these fields: + +| Field | Required | Meaning | +| --- | --- | --- | +| `name` | Yes | Lowercase identifier unique within the manifest | +| `parameters` | Yes | Map of HDL parameter identifiers to scalar values | +| `flows` | Yes | Nonempty list of evidence required for this elaboration | +| `tops` | No | Per-profile overrides for `design`, `testbench`, `formal`, or `pyuvm` | + +Parameter values may be JSON integers, booleans, or whitespace-free HDL +constant expressions. Booleans become `1` and `0` at tool boundaries. +Whitespace, double quotes, semicolons, and dollar signs are rejected. Use an +ordinary HDL identifier for every parameter and top name. + +The supported profile flows are: + +```text +verible_lint verible_format +slang_elaboration verilator_lint +yosys_synthesis symbiyosys_formal +eqy_equivalence verilator_sim +pyuvm_open_source negative_qualification +four_state_qualification static_intent +vcs_sim pyuvm_commercial +synopsys_synthesis synopsys_primetime +synopsys_primepower +``` + +`eqy_equivalence` requires `yosys_synthesis` in the same profile. +`synopsys_primetime` requires `synopsys_synthesis`. +`synopsys_primepower` requires both `vcs_sim` and `synopsys_synthesis`. +Unsupported flows and incomplete dependency sets fail manifest validation. +The profile list may narrow the module's `FLOW_` policy, but it cannot +enable a flow disabled there. Declaring a disabled module flow as required by a +selected profile is a configuration error. + +## Select and run profiles + +Validate and inspect the declaration before launching tools: + +```sh +make profile-manifest-check +make profile-list +make profile-matrix +``` + +Run one exact elaboration with any normal target: + +```sh +make PROFILE=width_min flow-config-check +make PROFILE=width_min clean open-source +``` + +When a manifest exists, a flow target without `PROFILE` fails before a tool +starts. Flows omitted from the selected profile are resolved as disabled and +record `SKIP` when an aggregate target invokes them. + +Run one target for every declared profile: + +```sh +make clean all-profiles +make all-profiles PROFILE_TARGET=open-formal PROFILE_JOBS=4 +``` + +`PROFILE_TARGET` defaults to `open-source`. `PROFILE_JOBS=0` lets GNU `xargs` +use all available processors. A positive value bounds concurrency. Setup is +performed once before parallel portable jobs begin. + +The aggregate keeps completed results, writes +`reports/parameter-profile-summary.json`, and returns nonzero when any child +invocation fails. The summary preserves `PASS`, `FAIL`, `SKIP`, `BLOCKED`, and +missing evidence so one profile cannot hide another profile's outcome. + +## Evidence isolation + +A selected profile adds its name below the module's configured roots: + +```text +reports//parameter-profile.json +reports///status.txt +work///... +``` + +In a multi-module repository, the normal module namespace comes first: + +```text +reports//// +work//// +``` + +`parameter-profile.json` records the exact parameter map, applicable flows, and +resolved top names. Generated formal and equivalence configurations are stored +with the profile reports. The EQY gate input is always resolved below the same +profile's `work//yosys_synthesis/` tree. PrimeTime and PrimePower also +inherit the profile-qualified Design Compiler artifact paths. + +The module-owned EQY configuration must contain exactly one `.v` or `.sv` input +in its `[gate]` section. This input is the placeholder replaced by the selected +profile's netlist. An ambiguous gate input set fails before EQY starts. + +`make PROFILE= clean` removes only that profile. Run `make clean` without +a profile to remove every generated profile result. + +## Backend mapping + +The canonical parameter map reaches supported tools as follows: + +| Flow | Applied to | Backend mechanism | +| --- | --- | --- | +| Slang elaboration | `DESIGN_TOP` | `-G NAME=VALUE` | +| Verilator lint | `DESIGN_TOP` | `-GNAME=VALUE` | +| Yosys synthesis | `DESIGN_TOP` | `chparam -set` before `hierarchy` | +| SymbiYosys | `FORMAL_TOP` | Rendered profile `.sby` with `chparam` before `prep` | +| EQY gold | `DESIGN_TOP` | Rendered profile `.eqy` with `chparam` before `prep` | +| EQY gate | Matching netlist | Exact profile-local Yosys netlist path | +| Verilator simulation | `TB_TOP` | `-GNAME=VALUE` | +| VCS simulation | `TB_TOP` | `-pvalue+TOP.NAME=VALUE` | +| PyUVM | `PYUVM_TOP` | cocotb runner `parameters` map | +| Design Compiler | `DESIGN_TOP` | `elaborate -parameters` | +| PrimeTime and PrimePower | DC output | Matching profile-local DDC and SDC | + +Simulation testbench tops must declare the profile parameters and propagate +them into the DUT. Formal harness tops must do the same. PyUVM normally uses the +DUT itself as `PYUVM_TOP`, so its overrides apply directly. + +Verible performs source syntax, formatting, and style checks without elaborating +parameter values. Its result is still profile-qualified when selected, but it +does not independently prove a parameter branch. + +## Choose required evidence + +An elaboration-only profile is appropriate when it checks parser legality for a +representative value and cannot activate a structurally distinct generate path. +Such a profile normally selects Verible, Slang, and Verilator lint. + +Require simulation or formal when a value changes behavior. Require synthesis +when it changes widths, generated hierarchy, arithmetic implementation, memory +shape, or another structural branch. Require EQY whenever a profile's synthesized +netlist is release evidence. Technology timing and power profiles should include +Design Compiler and their matching downstream commercial flows. + +Keep the matrix representative. Include legal minimum and maximum boundaries, +the nominal production configuration, and both sides of each important feature +toggle. Document exclusions and equivalent classes in the module verification +plan. + +## GitHub Actions integration + +For a single module, emit `make profile-matrix` as compact JSON and run each +entry independently: + +```yaml +- name: Generate parameter matrix + id: profiles + run: echo "matrix=$(make profile-matrix)" >> "${GITHUB_OUTPUT}" + +# In a matrix job: +- run: make PROFILE="${{ matrix.profile }}" clean open-source +``` + +For a multi-module repository, `make module-profile-matrix` combines every +registered module with its own profiles. Modules without a manifest contribute +one `default` entry. + +```yaml +- name: Generate module and profile matrix + id: qualifications + run: echo "matrix=$(make module-profile-matrix)" >> "${GITHUB_OUTPUT}" + +# In a matrix job: +- run: >- + make MODULE="${{ matrix.module }}" + PROFILE="${{ matrix.profile }}" clean open-source +``` + +Use `${{ matrix.job_name }}` in job or artifact names. It is generated as +`--` and is validated for collisions. Use the same matrix for +native and pinned-container jobs so both environments qualify identical +elaborations. diff --git a/docs/qualification-campaigns.md b/docs/qualification-campaigns.md new file mode 100644 index 0000000..f8eff84 --- /dev/null +++ b/docs/qualification-campaigns.md @@ -0,0 +1,237 @@ +# Negative-test and four-state qualification + +The optional qualification campaigns prove that a module's verification +environment can detect known design faults and illegal four-state control +values. `mosaic-flow` owns campaign validation, orchestration, classification, +and evidence. The consuming module owns the mutations, invalid configurations, +stimulus, expected diagnostics, and decision to enable either gate. + +The two campaigns are independent canonical flows: + +| Campaign | Flow ID | Target | Default | +| --- | --- | --- | --- | +| Negative tests | `negative_qualification` | `make open-negative` | Disabled | +| Four-state controls | `four_state_qualification` | `make open-four-state` | Disabled | + +`make open-source` invokes both targets. A disabled campaign records an explicit +`SKIP`. Once enabled, its result must be `PASS` at the open-source quality gate +and in release evidence. + +## Module configuration + +Store the module-owned manifest at +`config/qualification-campaigns.json`, or override its path: + +```make +export QUALIFICATION_CAMPAIGN_MANIFEST := $(CURDIR)/config/qualification-campaigns.json +``` + +Enable the policies independently in `config/flows.mk`: + +```make +FLOW_negative_qualification := enabled +FLOW_four_state_qualification := enabled + +FLOW_DEPENDENCIES_negative_qualification := +FLOW_DEPENDENCIES_four_state_qualification := +``` + +The schema is +[`schemas/qualification-campaigns-v1.schema.json`](../schemas/qualification-campaigns-v1.schema.json). +The runtime also applies semantic checks that JSON Schema cannot express, such +as control references and expected-failure rules. + +## Manifest model + +The root may declare either or both campaigns. A selected campaign must exist. +Each case has a stable `id`, a filesystem-safe `evidence` name, a semantic +`role`, a descriptive `kind`, and one or two ordered `phases` named `compile` +or `run`. + +Each campaign may also list module-relative `inputs`. These paths identify its +testbenches, mutations, candidate netlists, and tool configuration. The runner +rejects missing or external paths, and the release generator hashes the inputs +for each enabled campaign. + +```json +{ + "schema": "mosaic-qualification-campaigns-v1", + "campaigns": { + "negative": { + "inputs": [ + "verif/mutations/counter_mutant.sv", + "verif/formal/counter_bad_netlist.eqy" + ], + "cases": [ + { + "id": "nominal_control", + "evidence": "nominal_control", + "role": "positive_control", + "kind": "positive_control", + "phases": [ + { + "name": "run", + "command": ["{case_work_dir}/nominal_sim"], + "expected": "success", + "diagnostic": "NOMINAL_TEST_PASS" + } + ] + }, + { + "id": "incorrect_reset_mutation", + "evidence": "incorrect_reset_mutation", + "role": "negative", + "kind": "simulation_mutation", + "positive_control": "nominal_control", + "phases": [ + { + "name": "run", + "command": ["{case_work_dir}/mutant_sim"], + "expected": "failure", + "diagnostic": "RESET_ASSERTION_FAILED", + "failure_class": "assertion" + } + ] + } + ] + } + } +} +``` + +A phase defines exactly one `command` string array or one executable `fixture`. +Commands run directly without an implicit shell. Optional fields are +`environment`, `timeout_seconds`, and `diagnostic`. A phase expecting failure +must also declare a diagnostic regular expression and one of these classes: + +- `assertion` +- `elaboration` +- `equivalence` +- `mutation` +- `unknown_control` + +Available placeholders are `{module_root}`, `{flow_root}`, `{report_dir}`, +`{work_dir}`, `{case_report_dir}`, `{case_work_dir}`, `{python}`, `{verilator}`, +`{eqy}`, `{iverilog}`, and `{vvp}`. Module source paths remain relative to +`MODULE_ROOT`. + +## Negative-test contract + +Every negative case references a successful `positive_control`. This prevents +an always-failing checker, broken simulator, or invalid nominal configuration +from qualifying a detected fault. The campaign supports: + +- Simulation mutations detected by a test or assertion +- Deliberate assertion failures +- Invalid-parameter elaboration failures +- Deliberately inequivalent candidate netlists + +The expected nonzero exit alone is insufficient. The declared diagnostic must +also appear. If a command succeeds, the case is classified as `escaped_fault`. +If it fails without the expected design-check diagnostic, the result is +`unexpected_failure` or `infrastructure_failure`, never a false qualification. + +Additional infrastructure signatures may be declared at campaign level: + +```json +"infrastructure_diagnostics": ["LICENSE_SERVER_UNAVAILABLE"] +``` + +Built-in signatures cover missing executables, missing files, license checkout +failures, internal tool failures, and the fixture marker +`INFRASTRUCTURE_FAILURE`. + +## Keeping mutations separate + +Do not edit production RTL in place and do not copy the shared campaign runner +into a module. Keep module-owned mutation material under a verification path, +for example: + +```text +verif/ +|-- mutations/ +| |-- counter_mutant.sv +| `-- bad_candidate_netlist.v +|-- formal/fault_injection/ +| `-- counter_bad_netlist.eqy +`-- tb/fault_injection/ + `-- counter_fault_tb.sv +``` + +Prefer a compile-time mutation macro, a bind-time fault shim, or a small +candidate netlist over a second full RTL implementation. Compile each mutation +only in its declared case. The positive control must compile and run the +unmodified design with the same checker whenever the tool and test permit it. + +## Four-state contract + +The four-state campaign is pinned to `"simulator": "iverilog"`. It is separate +from the normal two-state Verilator gate. Every `unknown_detection` case: + +- Declares the control ports and injected `X`, `Z`, or both values in + `injections` +- References one `disabled_monitor_control` +- Expects a nonzero result and a module-specific monitor diagnostic + +The disabled-monitor control must drive the same illegal stimulus while the +unknown-value monitor is disabled. It expects success and requires a marker +such as `UNKNOWN_CONTROL_STIMULUS_REACHED`. This proves the stimulus reached the +DUT and catches monitors that fail unconditionally. + +Both cases declare identical `injections`, provide `compile` and `run` phases, +and invoke `{iverilog}` and `{vvp}` directly. The runner enforces these details +so simulator metadata cannot claim Icarus while executing a two-state backend. + +The `injections` field is auditable metadata. The module-owned four-state +testbench remains responsible for driving the declared values. Reviewers should +compare the testbench with this declaration. + +## Results and classification + +Each gate writes: + +```text +reports// +|-- status.txt +|-- summary.json +`-- [/]/ + |-- status.txt + |-- summary.json + |-- compile-command.json + |-- compile-result.json + |-- compile.log + |-- run-command.json + |-- run-result.json + `-- run.log +``` + +Only declared phases are present. Corresponding disposable outputs use +`work//[/]/`. Four-state cases use the +explicit `iverilog/` namespace. Multi-module and parameter-profile roots +continue to isolate these paths before the flow ID. + +Case summaries distinguish expected assertion, elaboration, equivalence, +mutation, and unknown-control failures from escaped faults, missing diagnostics, +unexpected failures, infrastructure failures, and failed controls. Any failed +or blocked case fails the aggregate campaign. + +The release manifest hashes the module-owned campaign manifest when either +flow is enabled. It records each flow's required or approved-skip policy, +captures Icarus and VVP versions when four-state qualification is enabled, and +indexes each generated campaign `summary.json` as additional evidence. + +## Migration from module scripts + +To replace an existing `run_fault_injection.sh` or `run_four_state.sh`: + +1. Inventory each compile and run command, expected exit, and diagnostic. +2. Add a positive or disabled-monitor control before its dependent cases. +3. Move orchestration into the manifest while retaining design-specific RTL, + testbenches, EQY files, and expected messages in the module. +4. Run each target independently and inspect escaped-fault and broken-monitor + behavior deliberately. +5. Enable the flow only after its positive and negative controls qualify. + +Automatic mutation generation, replacement of normal regression or formal +proof, and mandatory four-state testing for modules without relevant controls +are outside this feature. diff --git a/docs/release-evidence.md b/docs/release-evidence.md new file mode 100644 index 0000000..59f9e1b --- /dev/null +++ b/docs/release-evidence.md @@ -0,0 +1,236 @@ +# Release evidence + +## Purpose + +`make release-manifest` turns the distributed results below `REPORT_DIR` into +one auditable qualification record. It does not rerun flows and it does not +replace engineering review. It validates the resolved flow policy, rejects +incomplete required evidence, hashes release inputs, records tool versions, and +indexes compact module-owned evidence. + +The versioned JSON contract is +[`schemas/release-evidence-v1.schema.json`](../schemas/release-evidence-v1.schema.json). +The generator performs the same structural and semantic checks without adding a +third-party Python dependency. + +## Generate a manifest + +Run all checks required by the module policy first. Then generate and validate +the evidence: + +```sh +make open-source +make synopsys-all +make \ + MODULE_REVISION="$(git rev-parse HEAD)" \ + METHODOLOGY_REVISION="$(git -C mosaic-flow rev-parse HEAD)" \ + release-manifest release-manifest-validate +``` + +Both revisions must be full 40-digit commit IDs when `CI=true`. They may be +omitted during local development, in which case the generator resolves each +repository's `HEAD`. A dirty module or methodology checkout fails by default. A local +diagnostic may set `RELEASE_ALLOW_DIRTY=enabled`, and the resulting manifest +records the dirty state. Do not publish that manifest as qualified release +evidence. + +When a container copies source without its `.git` directory, Git cannot inspect +that tree. An immutable image build may explicitly attest +`RELEASE_MODULE_DIRTY=false` or `RELEASE_METHODOLOGY_DIRTY=false` for the +packaged tree. Set the value to `true` when the packaged source included local +changes. Invalid values and unavailable repositories without an attestation +fail closed. Prefer automatic Git inspection whenever metadata is present. + +Outputs are context-specific: + +```text +reports/release_manifest/native/ +|-- manifest.json +|-- status.txt +`-- summary.txt +``` + +Selected modules and parameter profiles keep the same directory qualification +already applied to `REPORT_DIR`. Set `RELEASE_EXECUTION_CONTEXT=container` for +a required container run. Native and container evidence then coexist instead +of overwriting each other. + +## Acceptance policy + +The manifest evaluates every canonical ID from `MOSAIC_FLOW_IDS`: + +- An enabled flow must have `status.txt` containing exactly `PASS`. +- A disabled flow is recorded as an approved `SKIP`. Its status file may be + absent, or it may contain exactly `SKIP`. +- `FAIL`, `BLOCKED`, an unauthorized `SKIP`, a stale `PASS` for disabled policy, + and missing enabled-flow evidence reject the manifest. + +Module-owned checks use the same fail-closed status contract: + +```make +export RELEASE_SUPPLEMENTAL_GATES := \ + constraint_check \ + assertion_coverage \ + fault_injection +``` + +Each registered gate must provide `$(REPORT_DIR)//status.txt` containing +`PASS`. This mechanism supports qualification policy without adding +module-specific logic to `mosaic-flow`. + +## Inputs and hashes + +The Make API automatically declares the selected design and flow +configuration, module and parameter-profile manifests, known filelists, +constraints, waivers, formal and equivalence configuration, OpenROAD setup, +CDC and DFT intent, UPF, and PyUVM sources. Every discovered file is recorded +with a repository-relative path and SHA-256 digest. + +When `static_intent` is enabled, the generator reads the module expectation +file and hashes every SDC and UPF path it selects. It also indexes the separate +SDC and UPF findings plus the aggregate summary as additional evidence. + +Filelists are traversed recursively. The parser accepts source paths, +`+incdir+`, `+define+`, and nested `-f` or `-F` entries. Missing declared files, +empty declared directories, paths outside `MODULE_ROOT`, and unsupported +filelist options fail generation. Generated `__pycache__`, `.pyc`, and `.pyo` +files are excluded from directory inputs. + +Add reviewed release inputs such as a checklist or workload definition with: + +```make +export RELEASE_ADDITIONAL_INPUTS := \ + docs/dff/release-checklist.md \ + verif/vectors/release +``` + +Add compact generated evidence, rather than large databases, with: + +```make +export RELEASE_ADDITIONAL_EVIDENCE := \ + $(REPORT_DIR)/constraint_check/summary.json \ + $(REPORT_DIR)/fault_injection/summary.json +``` + +## Tools and coverage + +The manifest queries the exact enabled portable tools configured by the Make +API. It records stable tool names, commands, versions, and associated flows in +the deterministic section. Host-specific executable paths are kept in the +volatile execution section. + +When PyUVM is enabled, `versions.log` supplies separate Python, PyUVM, cocotb, +and simulator records. The version log itself is hashed. Native SystemVerilog +coverage databases and reports are indexed separately from +`functional-coverage.json`, so Python functional coverage is never confused +with HDL coverage. When the canonical coverage gate runs, its +`coverage_qualification/summary.json` is indexed as a separate qualification +decision alongside the underlying databases. + +When enabled, `negative_qualification` and `four_state_qualification` are +recorded as independent required canonical flows. Their module-owned campaign +manifest is hashed as a release input and each aggregate `summary.json` is +indexed as additional evidence. Four-state releases also capture the resolved +Icarus and VVP versions. + +When enabled, `static_intent` is recorded as an independent required canonical +flow. Its evidence establishes portable structural intent only and does not +replace STA or VC LP signoff evidence. + +When `openroad` is enabled and not explicitly disabled for the release, the +generator hashes `OPENROAD_EVIDENCE_POLICY`, the selected ORFS configuration, +and the exact SDC. It also requires and indexes +`reports/openroad/evidence.json`. That compact record includes the immutable +container identity or local ORFS revision, parsed physical metrics, and hashes +for every required deliverable. A passing exploratory ORFS run is not foundry +signoff. + +Register another tool version command with JSON: + +```make +export RELEASE_ADDITIONAL_TOOLS_JSON := \ + [{"name":"custom_analyzer","command":["custom-analyzer","--version"],"flows":[]}] +``` + +An empty `flows` list means the tool is always required. Otherwise, it is +recorded when at least one listed flow is enabled. + +Additional coverage records use `kind`, `producer`, and a module-relative or +absolute in-repository path: + +```make +export RELEASE_COVERAGE_EVIDENCE_JSON := \ + [{"kind":"formal_cover_summary","producer":"formal_review","path":"reports/formal_review/coverage.json"}] +``` + +## Technology and metadata + +Describe a technology-independent run or the exact implementation context: + +```make +export RELEASE_TECHNOLOGY := gf180mcu +export RELEASE_TECHNOLOGY_METADATA_JSON := \ + {"pdk_revision":"","library":"gf180mcu_fd_sc_mcu7t5v0","corner":"ss_125c"} +``` + +`RELEASE_METADATA_JSON` accepts deterministic module-owned data such as the +release checklist revision, workload, or seed policy. +`RELEASE_EXECUTION_METADATA_JSON` accepts volatile runner data such as a CI run +ID, image digest, or host label. All JSON variables must contain an object. + +## Deterministic comparison + +`manifest.json` has two top-level data sections: + +- `deterministic` contains revisions, selected parameters, technology, flow and + gate decisions, versions, input hashes, evidence hashes, and module metadata. +- `volatile` contains generation time, separate module and methodology dirty + states, host details, executable + locations, and execution metadata. + +Compare `deterministic` when checking whether two executions qualified the +same inputs and policy. Compare `volatile.execution.context` and uploaded +artifacts when native and container execution are both release requirements. + +## CI and release checklists + +A release job should run required flows, generate the manifest with explicit +revisions, validate it, and upload the entire context directory even on later +job failure. Reference the archived `manifest.json` artifact and CI run from +the module's release checklist. + +```yaml +- name: Generate release evidence + run: | + make \ + MODULE_REVISION="${GITHUB_SHA}" \ + METHODOLOGY_REVISION="$(git -C mosaic-flow rev-parse HEAD)" \ + RELEASE_EXECUTION_CONTEXT=container \ + release-manifest release-manifest-validate + +- name: Upload release evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: release-evidence-container + path: reports/release_manifest/container/ +``` + +For a multi-module repository, select `MODULE=`. For a parameterized +module, also select `PROFILE=`. Generate one manifest for every +module/profile/context combination required by the release checklist. + +## Migrating module-owned scripts + +Replace a custom release-manifest generator with the shared target: + +1. Map custom status checks to `RELEASE_SUPPLEMENTAL_GATES`. +2. Map reviewed source artifacts to `RELEASE_ADDITIONAL_INPUTS`. +3. Map compact generated summaries to `RELEASE_ADDITIONAL_EVIDENCE`. +4. Move technology and release annotations into the JSON metadata variables. +5. Generate new evidence only after all required flow statuses exist. +6. Remove the duplicate script after native and container outputs have been + compared against the previous release record. + +The module retains ownership of its policy and evidence. The methodology owns +only validation, indexing, schema versioning, and the public target. diff --git a/docs/results-and-quality-gates.md b/docs/results-and-quality-gates.md index f53f928..531c7c0 100644 --- a/docs/results-and-quality-gates.md +++ b/docs/results-and-quality-gates.md @@ -14,6 +14,18 @@ work// logs, and compact summaries. Work directories contain generated executables, netlists, proof databases, and tool state. +Named parameter profiles add a namespace before the flow ID: + +```text +reports/// +work/// +``` + +Multi-module repositories use `//`. Every selected +profile records `parameter-profile.json`, while `all-profiles` records the +cross-profile `parameter-profile-summary.json` at the unqualified report root. +See [Parameter-profile qualification](parameter-profiles.md). + `make clean` removes the complete work tree and every item below `reports/` except `.gitkeep`. It is idempotent and succeeds when either generated root does not exist, including on a fresh module checkout. @@ -67,10 +79,19 @@ the dependency graph and filesystem are temporarily inconsistent. - `eqy_equivalence` - `verilator_sim` - `pyuvm_open_source` +- `coverage_qualification` +- `negative_qualification` +- `four_state_qualification` +- `static_intent` `make open-source` runs these targets and then the gate. PyUVM is disabled by -default and therefore records `SKIP` unless the module enables it. Once enabled, -its open-source result must be `PASS`. `openroad` is optional and intentionally +default and therefore records `SKIP` unless the module enables it. Coverage +qualification is also disabled by default. Negative-test and four-state +qualification are likewise disabled by default and independently selectable. +Static intent validation is disabled by default and becomes required when a +module enables it. +Once any optional flow is enabled, its open-source result must be `PASS`. +`openroad` is optional and intentionally outside this portable gate. ## Commercial quality gate @@ -131,12 +152,24 @@ The fixture integration job runs: ```sh make -C tests/fixture-module FLOW_ROOT="$GITHUB_WORKSPACE" clean open-source +make -C tests/fixture-multi-module FLOW_ROOT="$GITHUB_WORKSPACE" clean all-modules MODULE_JOBS=2 +make -C tests/fixture-parameter-profiles FLOW_ROOT="$GITHUB_WORKSPACE" clean all-profiles PROFILE_JOBS=4 ``` It caches pinned tools and uploads fixture reports even when the flow fails. The fixture is deliberately independent of `mosaic-module-template`, so the methodology can prove its own consumer contract before release. +The multi-module fixture independently qualifies named and fallback input +resolution, module-specific policy loading, formatter options, isolated work +and reports, aggregate failure propagation, and deterministic CI matrix output. + +The parameter-profile fixture qualifies a minimum width, nominal width, feature +toggle, and elaboration-only profile. It checks profile-local synthesis, +formal, simulation, PyUVM, and EQY evidence, plus explicit `SKIP` results for +flows outside the elaboration-only policy. Its negative tests require aggregate +failure for failed, blocked, and missing profile evidence. + The fixture keeps `PROPERTY_FILELIST`, `ASSERTION_FILELIST`, and `COVERAGE_FILELIST` separate, then uses each list in its normal testbench, PyUVM environment, and formal tasks. Assertion and coverage wrappers include @@ -151,24 +184,14 @@ retain `FAIL`. ## Release evidence -A module release should retain enough information to reproduce and review the -decision. At minimum, record: - -- Module Git revision -- `mosaic-flow` Git revision and semantic version -- Tool names and versions -- Flow configuration and selected CDC engine -- Constraint, UPF, and waiver revisions -- PDK, libraries, operating condition, and analysis corner where applicable -- Status and principal reports for every enabled flow -- Test identity, seed policy, and functional coverage summary -- Formal properties and proof status -- Activity source and annotation coverage for power analysis -- Date and execution environment - -Generated databases and full logs should normally be stored as CI or release -artifacts. Small reviewed manifests and source-controlled waiver records belong -in Git. Do not commit licensed libraries, credentials, or large work databases. +`make release-manifest` validates every canonical flow status and creates a +schema-versioned JSON index plus a short text summary. It records revisions, +dirty state, technology, tool versions, input hashes, coverage, supplemental +gates, and execution context. See [Release evidence](release-evidence.md) for +the complete policy, configuration, CI example, and migration procedure. + +Generated databases and full logs should remain CI or release artifacts. Do +not commit licensed libraries, credentials, or large work databases. ## Interpreting failures diff --git a/docs/static-intent.md b/docs/static-intent.md new file mode 100644 index 0000000..62543f3 --- /dev/null +++ b/docs/static-intent.md @@ -0,0 +1,201 @@ +# Portable SDC and UPF intent validation + +The optional `static_intent` flow catches structural mistakes in module-owned +SDC and UPF before licensed tools or technology data are available. It captures +a documented Tcl subset without executing the input files, then compares the +captured commands with a module-owned JSON expectation file. + +This gate is an early intent check. It is **not static timing analysis, OpenSTA +signoff, PrimeTime signoff, IEEE 1801 conformance, or VC LP signoff**. It has no +timing graph, cell library, voltage model, power-aware netlist, or technology +rules. Commercial and PDK-backed flows remain authoritative for signoff. + +## Enable the flow + +Add the policy to the module's `config/flows.mk`: + +```make +FLOW_static_intent := enabled +``` + +Select the module-owned expectation file in `config/design.mk` when its path is +not the default `config/static-intent.json`: + +```make +export STATIC_INTENT_CONFIG := $(MODULE_ROOT)/config/static-intent.json +``` + +Run the check alone or as part of the portable gate: + +```sh +make open-static-intent +make open-source +``` + +The adapter writes: + +```text +reports/static_intent/sdc-findings.json +reports/static_intent/upf-findings.json +reports/static_intent/summary.json +reports/static_intent/status.txt +``` + +All three JSON reports and the status are indexed by release evidence when the +flow is enabled. The expectation file and every referenced SDC and UPF file are +hashed as release inputs. + +## Expectation file + +The complete schema is +[`schemas/static-intent-v1.schema.json`](../schemas/static-intent-v1.schema.json). +A module may select SDC, UPF, or both. + +```json +{ + "schema": "mosaic-static-intent-v1", + "sdc": { + "profiles": [ + { + "name": "synthesis", + "path": "flows/synthesis/timing.sdc", + "kind": "sequential", + "expected_ports": { + "clock": ["i_clk"], + "input_delay": ["i_data", "i_enable"], + "output_delay": ["o_data"] + }, + "clocks": [ + {"name": "i_clk", "period": 10.0, "port": "i_clk"} + ], + "intentional_exceptions": [ + { + "command": "set_false_path", + "from_ports": ["i_rstb"], + "to_ports": [] + } + ] + }, + { + "name": "physical", + "path": "flows/openroad/timing.sdc", + "kind": "sequential" + } + ], + "consistency": [ + { + "profiles": ["synthesis", "physical"], + "commands": [ + "create_clock", + "set_clock_uncertainty", + "set_input_delay", + "set_output_delay" + ] + } + ] + }, + "upf": { + "path": "flows/vc_lp/power.upf", + "top": "example", + "mode": "always_on", + "required": { + "power_domains": ["PD_EXAMPLE"], + "supply_ports": ["VDD", "VSS"], + "supply_nets": ["VDD", "VSS"], + "supply_connections": ["VDD", "VSS"], + "domain_supplies": ["PD_EXAMPLE"] + }, + "forbidden_strategies": [] + } +} +``` + +`kind` applies minimum and forbidden-command policy: + +| Kind | Required intent | Default forbidden intent | +| --- | --- | --- | +| `sequential` | Clock, uncertainty, input delay, output delay | None beyond undeclared broad exceptions | +| `combinational` | Maximum input-to-output delay | Clocks, generated clocks, false paths, clock groups | +| `clock_gating` | Source clock, generated clock, uncertainty, input delay | False paths, clock groups, output delay on the clock output | +| `reset_synchronizer` | Destination clock, uncertainty, synchronized output delay | Clock groups | + +`expected_ports` proves that each named interface port is selected by the +appropriate command. Direct `get_ports`, `all_inputs`, `all_outputs`, and +`remove_from_collection` selectors are understood. Every `set_false_path` must +use concrete ports and match an `intentional_exceptions` entry. Broad or +undeclared exceptions fail. + +Consistency rules compare normalized command captures, so harmless whitespace +and numeric formatting differences do not affect port coverage. Command +differences between selected synthesis, asynchronous, and physical profiles are +reported as `profile_mismatch` findings. + +## UPF policy + +`mode: always_on` rejects isolation, level-shifting, retention, and power-switch +strategies. `mode: boundary` allows strategies selected by `required` and still +rejects every strategy listed in `forbidden_strategies`. + +The `required` section can name: + +- `power_domains` +- `supply_ports` +- `supply_nets` +- `supply_connections` +- `domain_supplies` +- `port_states` as `.` +- `power_state_tables` +- `power_states` as `.` +- `port_attribute_ports` +- `isolation` +- `level_shifting` +- `retention` + +The validator checks references between domains, supplies, connections, port +states, power-state tables, and strategies. It also checks minimum structural +options for isolation, level shifting, and retention. It does not prove that a +strategy maps to a valid library cell or that a power sequence is electrically +safe. + +## Supported Tcl subset + +The parser does not execute Tcl. Comments, line continuations, braces, quoted +literals, command substitutions, and the following collection expressions are +captured: + +- `get_ports`, `get_clocks`, `get_pins`, `get_cells` +- `all_inputs`, `all_outputs` +- `remove_from_collection` + +Supported SDC commands are: + +- `create_clock`, `create_generated_clock` +- `set_clock_uncertainty` +- `set_input_delay`, `set_output_delay`, `set_max_delay` +- `set_false_path`, `set_clock_groups` +- `set_input_transition`, `set_load`, `set_case_analysis` + +Supported UPF commands are: + +- `set_design_top`, `create_power_domain` +- `create_supply_port`, `create_supply_net`, `connect_supply_net` +- `set_domain_supply_net`, `add_port_state` +- `create_pst`, `add_pst_state`, `add_power_state` +- `set_port_attributes` +- `set_isolation`, `set_isolation_control` +- `set_level_shifter` +- `set_retention`, `set_retention_control` +- `create_power_switch` + +An unsupported command, query, option, variable substitution, or Tcl control +construct is an explicit error. Extend and qualify the shared command model when +a portable module genuinely needs another construct. Do not add module-specific +parser code. + +## Review guidance + +Use the JSON finding code as the stable automation interface and the message, +source, and line fields for diagnosis. Resolve missing or conflicting intent in +the module-owned SDC, UPF, or expectation file. Waiving this gate by weakening +the parser would affect every module and therefore requires methodology-level +review. diff --git a/flows/coverage/run.sh b/flows/coverage/run.sh new file mode 100755 index 0000000..ea528dd --- /dev/null +++ b/flows/coverage/run.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +source "$(dirname "$0")/../common/env.sh" + +flow_report_dir="${REPORT_DIR}/coverage_qualification" +flow_work_dir="${WORK_DIR}/coverage_qualification" +mkdir -p "${flow_report_dir}" "${flow_work_dir}" +printf 'FAIL\n' > "${flow_report_dir}/status.txt" + +record_early_failure() { + local source="$1" + local message="$2" + "${COVERAGE_QUALIFICATION_TOOL}" record-failure \ + --source "${source}" --message "${message}" \ + --output "${flow_report_dir}/summary.json" + echo "${message}" >&2 + exit 1 +} + +source_name="${COVERAGE_QUALIFICATION_SOURCE:-verilator_sim}" +case "${source_name}" in + verilator_sim|pyuvm_open_source) + source_report_dir="${REPORT_DIR}/${source_name}" + if [[ ! -f "${source_report_dir}/status.txt" ]] || + [[ "$(<"${source_report_dir}/status.txt")" != "PASS" ]]; then + record_early_failure "${source_name}" "Coverage source ${source_name} must have PASS status" + fi + ;; + dedicated) + source_name=dedicated + source_report_dir="${flow_report_dir}/dedicated" + if ! SIMULATOR=verilator \ + MOSAIC_SIM_REPORT_DIR="${source_report_dir}" \ + MOSAIC_SIM_WORK_DIR="${flow_work_dir}/dedicated" \ + MOSAIC_SIM_FORCE_COVERAGE=enabled \ + "${FLOW_ROOT}/flows/sim/run.sh"; then + record_early_failure "${source_name}" "Dedicated coverage simulation failed" + fi + ;; + *) + record_early_failure "${source_name}" \ + "COVERAGE_QUALIFICATION_SOURCE must be verilator_sim, pyuvm_open_source, or dedicated" + ;; +esac + +set +e +formal_required="$(${COVERAGE_QUALIFICATION_TOOL} inspect \ + --policy "${COVERAGE_QUALIFICATION_POLICY}" \ + --module-root "${MODULE_ROOT}" --field formal.required)" +policy_status="$?" +set -e +if [[ "${policy_status}" -ne 0 ]]; then + "${COVERAGE_QUALIFICATION_TOOL}" qualify \ + --policy "${COVERAGE_QUALIFICATION_POLICY}" --module-root "${MODULE_ROOT}" \ + --native "${source_report_dir}/coverage.dat" --lcov "${source_report_dir}/coverage.info" \ + --source "${source_name}" --formal-result SKIP \ + --output "${flow_report_dir}/summary.json" +fi +formal_result=SKIP +formal_reached=0 +if [[ "${formal_required}" == "true" ]]; then + if [[ -z "${FORMAL_COVER_CONFIG:-}" ]]; then + record_early_failure "${source_name}" "FORMAL_COVER_CONFIG is required by the coverage policy" + fi + + coverage_work_dir="${flow_work_dir}/formal" + formal_cover_config="${FORMAL_COVER_CONFIG}" + if [[ "${MOSAIC_PROFILE_ACTIVE:-disabled}" == "enabled" ]]; then + formal_cover_config="${flow_report_dir}/formal-profile.sby" + python3 "${PARAMETER_PROFILE_TOOL}" render-sby \ + --input "${FORMAL_COVER_CONFIG}" --output "${formal_cover_config}" \ + --parameters "${PROFILE_PARAMETERS_JSON}" --top "${FORMAL_TOP}" + fi + set +e + ( + cd "${REPO_ROOT}" || exit 2 + "${SBY_CMD:-sby}" -f -d "${coverage_work_dir}" "${formal_cover_config}" + ) 2>&1 | tee "${flow_report_dir}/formal.log" + formal_exit="${PIPESTATUS[0]}" + set -e + formal_reached="$(grep -c 'Reached cover statement' "${flow_report_dir}/formal.log" || true)" + if [[ "${formal_exit}" -eq 0 && -f "${coverage_work_dir}/PASS" ]]; then + formal_result=PASS + else + formal_result=FAIL + fi +fi + +"${COVERAGE_QUALIFICATION_TOOL}" qualify \ + --policy "${COVERAGE_QUALIFICATION_POLICY}" \ + --module-root "${MODULE_ROOT}" \ + --native "${source_report_dir}/coverage.dat" \ + --lcov "${source_report_dir}/coverage.info" \ + --source "${source_name}" --formal-result "${formal_result}" \ + --formal-reached "${formal_reached}" \ + --output "${flow_report_dir}/summary.json" + +printf 'PASS\n' > "${flow_report_dir}/status.txt" diff --git a/flows/eqy/run.sh b/flows/eqy/run.sh index a8fcf32..e205dc1 100755 --- a/flows/eqy/run.sh +++ b/flows/eqy/run.sh @@ -6,8 +6,19 @@ flow_work_dir="${WORK_DIR}/eqy_equivalence" mkdir -p "${flow_report_dir}" "${flow_work_dir}" printf 'FAIL\n' > "${flow_report_dir}/status.txt" +# EQUIVALENCE_CONFIG is part of the module-owned exported environment contract. +# shellcheck disable=SC2153 +equivalence_config="${EQUIVALENCE_CONFIG}" +if [[ "${MOSAIC_PROFILE_ACTIVE:-disabled}" == "enabled" ]]; then + equivalence_config="${flow_report_dir}/profile.eqy" + python3 "${PARAMETER_PROFILE_TOOL}" render-eqy \ + --input "${EQUIVALENCE_CONFIG}" --output "${equivalence_config}" \ + --parameters "${PROFILE_PARAMETERS_JSON}" --top "${DESIGN_TOP}" \ + --netlist "${WORK_DIR}/yosys_synthesis/${DESIGN_TOP}_netlist.v" +fi + cd "${REPO_ROOT}" || exit 2 "${EQY_CMD:-eqy}" -f -d "${flow_work_dir}" \ - "${EQUIVALENCE_CONFIG}" 2>&1 | tee "${flow_report_dir}/equivalence.log" + "${equivalence_config}" 2>&1 | tee "${flow_report_dir}/equivalence.log" test -f "${flow_work_dir}/PASS" printf 'PASS\n' > "${flow_report_dir}/status.txt" diff --git a/flows/openroad/run.sh b/flows/openroad/run.sh index 0c769cf..1b35ecd 100755 --- a/flows/openroad/run.sh +++ b/flows/openroad/run.sh @@ -1,11 +1,155 @@ #!/usr/bin/env bash +set -euo pipefail + +# shellcheck source=../common/env.sh source "$(dirname "$0")/../common/env.sh" -if [[ -z "${OPENROAD_FLOW_ROOT:-}" ]]; then - echo "OPENROAD_FLOW_ROOT must point to an OpenROAD-flow-scripts checkout." >&2 - echo "Run this optional PDK-backed flow with OPENROAD_FLOW_ROOT and OPENROAD_PLATFORM set." >&2 - exit 2 -fi +flow_report_dir="${REPORT_DIR}/openroad" +orfs_work_home="${WORK_DIR}/openroad" +config_relative="" +constraint_relative="" +module_root_real="" +execution_mode="" + +module_relative_path() { + local candidate="$1" + local resolved + resolved="$(realpath -e "${candidate}")" + if [[ "${resolved}" != "${module_root_real}/"* ]]; then + echo "OpenROAD input must be owned by MODULE_ROOT: ${candidate}" >&2 + return 2 + fi + printf '%s\n' "${resolved#"${module_root_real}/"}" +} + +execution_runtime="" +runtime_version="" +orfs_revision="" +image_reference="" +image_digest="" +image_id="" + +prepare_openroad() { + local required_openroad_vars=( + OPENROAD_CONFIG + OPENROAD_CONSTRAINT_FILE + OPENROAD_EVIDENCE_POLICY + OPENROAD_EVIDENCE_TOOL + OPENROAD_PLATFORM + OPENROAD_FLOW_VARIANT + OPENROAD_DESIGN_NAME + ) + local variable_name + + for variable_name in "${required_openroad_vars[@]}"; do + if [[ -z "${!variable_name:-}" ]]; then + echo "Missing OpenROAD environment variable: ${variable_name}" >&2 + return 2 + fi + done + execution_mode="${OPENROAD_EXECUTION_MODE:-local}" + module_root_real="$(realpath -e "${MODULE_ROOT}")" || return $? + config_relative="$(module_relative_path "${OPENROAD_CONFIG}")" || return $? + constraint_relative="$(module_relative_path "${OPENROAD_CONSTRAINT_FILE}")" || return $? + module_relative_path "${OPENROAD_EVIDENCE_POLICY}" >/dev/null || return $? + mkdir -p "${flow_report_dir}" "${orfs_work_home}" +} + +run_orfs_local() { + if [[ -z "${OPENROAD_FLOW_ROOT:-}" || \ + ! -f "${OPENROAD_FLOW_ROOT}/flow/Makefile" ]]; then + echo "OPENROAD_FLOW_ROOT must identify an OpenROAD-flow-scripts checkout in local mode." >&2 + return 2 + fi + orfs_revision="$(git -C "${OPENROAD_FLOW_ROOT}" rev-parse HEAD 2>/dev/null || true)" + make -C "${OPENROAD_FLOW_ROOT}/flow" \ + DESIGN_CONFIG="${OPENROAD_CONFIG}" \ + DESIGN_NICKNAME="${OPENROAD_DESIGN_NAME}" \ + FLOW_VARIANT="${OPENROAD_FLOW_VARIANT}" \ + OPENROAD_PLATFORM="${OPENROAD_PLATFORM}" \ + PLATFORM="${OPENROAD_PLATFORM}" \ + REPO_ROOT="${module_root_real}" \ + SDC_FILE="${OPENROAD_CONSTRAINT_FILE}" \ + WORK_HOME="${orfs_work_home}" || return $? +} + +run_orfs_container() { + execution_runtime="${OPENROAD_CONTAINER_RUNTIME:-docker}" + image_reference="${OPENROAD_ORFS_IMAGE:-}" + if [[ ! "${image_reference}" =~ @(sha256:[0-9a-f]{64})$ ]]; then + echo "OPENROAD_ORFS_IMAGE must use an immutable @sha256 digest: ${image_reference}" >&2 + return 2 + fi + image_digest="${BASH_REMATCH[1]}" + if ! command -v "${execution_runtime}" >/dev/null 2>&1; then + echo "OpenROAD container runtime is unavailable: ${execution_runtime}" >&2 + return 2 + fi + runtime_version="$(${execution_runtime} --version | head -n 1)" || return $? + if ! "${execution_runtime}" image inspect "${image_reference}" >/dev/null 2>&1; then + "${execution_runtime}" pull "${image_reference}" || return $? + fi + image_id="$( + "${execution_runtime}" image inspect "${image_reference}" --format '{{.Id}}' + )" || return $? + + # Variables in the final command are intentionally expanded by the container. + # shellcheck disable=SC2016 + "${execution_runtime}" run --rm \ + --user "$(id -u):$(id -g)" \ + --env HOME=/tmp \ + --env MOSAIC_DESIGN_CONFIG="/workspace/${config_relative}" \ + --env MOSAIC_DESIGN_NAME="${OPENROAD_DESIGN_NAME}" \ + --env MOSAIC_FLOW_VARIANT="${OPENROAD_FLOW_VARIANT}" \ + --env MOSAIC_PLATFORM="${OPENROAD_PLATFORM}" \ + --env MOSAIC_SDC_FILE="/workspace/${constraint_relative}" \ + --mount "type=bind,src=${module_root_real},dst=/workspace,readonly" \ + --mount "type=bind,src=$(realpath -e "${orfs_work_home}"),dst=/orfs-work" \ + --entrypoint bash \ + "${image_reference}" \ + -lc 'source /OpenROAD-flow-scripts/env.sh && make -C /OpenROAD-flow-scripts/flow \ + DESIGN_CONFIG="${MOSAIC_DESIGN_CONFIG}" \ + DESIGN_NICKNAME="${MOSAIC_DESIGN_NAME}" \ + FLOW_VARIANT="${MOSAIC_FLOW_VARIANT}" \ + OPENROAD_PLATFORM="${MOSAIC_PLATFORM}" \ + PLATFORM="${MOSAIC_PLATFORM}" \ + REPO_ROOT=/workspace \ + SDC_FILE="${MOSAIC_SDC_FILE}" \ + WORK_HOME=/orfs-work' || return $? +} + +qualify_openroad() { + prepare_openroad || return $? + case "${execution_mode}" in + local) + run_orfs_local + ;; + container) + run_orfs_container + ;; + *) + echo "OPENROAD_EXECUTION_MODE must be local or container, found: ${execution_mode}" >&2 + return 2 + ;; + esac + + "${OPENROAD_EVIDENCE_TOOL}" \ + --module-root "${MODULE_ROOT}" \ + --policy "${OPENROAD_EVIDENCE_POLICY}" \ + --evidence-root "${orfs_work_home}" \ + --design-config "${OPENROAD_CONFIG}" \ + --constraint "${OPENROAD_CONSTRAINT_FILE}" \ + --output "${flow_report_dir}/evidence.json" \ + --execution-mode "${execution_mode}" \ + --design "${OPENROAD_DESIGN_NAME}" \ + --platform "${OPENROAD_PLATFORM}" \ + --variant "${OPENROAD_FLOW_VARIANT}" \ + --runtime "${execution_runtime}" \ + --runtime-version "${runtime_version}" \ + --orfs-revision "${orfs_revision}" \ + --image-reference "${image_reference}" \ + --image-digest "${image_digest}" \ + --image-id "${image_id}" +} -run_and_record openroad make -C "${OPENROAD_FLOW_ROOT}/flow" \ - DESIGN_CONFIG="${OPENROAD_CONFIG}" +run_and_record openroad qualify_openroad diff --git a/flows/pyuvm/run.py b/flows/pyuvm/run.py index 4edc56a..8ac071a 100644 --- a/flows/pyuvm/run.py +++ b/flows/pyuvm/run.py @@ -3,6 +3,7 @@ from __future__ import annotations +import json import os import platform import shlex @@ -205,6 +206,13 @@ def main() -> None: raise FileNotFoundError(f"Missing PyUVM HDL sources: {', '.join(missing_sources)}") build_args, test_args = simulator_arguments(simulator, coverage, work_dir) + raw_parameters = json.loads(os.environ.get("PROFILE_PARAMETERS_JSON", "{}")) + if not isinstance(raw_parameters, dict): + raise ValueError("PROFILE_PARAMETERS_JSON must contain a JSON object") + parameters = { + name: int(value) if isinstance(value, bool) else value + for name, value in raw_parameters.items() + } test_path = Path(os.environ.get("PYUVM_TEST_PATH", module_root / "verif" / "pyuvm")) python_path = [str(test_path.resolve())] if os.environ.get("PYTHONPATH"): @@ -221,6 +229,7 @@ def main() -> None: sources=sources, includes=includes, defines=defines, + parameters=parameters, build_args=build_args, hdl_toplevel=os.environ["PYUVM_TOP"], always=True, diff --git a/flows/qualification/run.sh b/flows/qualification/run.sh new file mode 100755 index 0000000..7e9ccb7 --- /dev/null +++ b/flows/qualification/run.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +source "$(dirname "$0")/../common/env.sh" + +campaign="${1:?Usage: run.sh }" +case "${campaign}" in + negative) + flow_id=negative_qualification + ;; + four_state) + flow_id=four_state_qualification + ;; + *) + echo "Unknown qualification campaign: ${campaign}" >&2 + exit 2 + ;; +esac + +"${QUALIFICATION_CAMPAIGN_TOOL}" \ + --campaign "${campaign}" \ + --manifest "${QUALIFICATION_CAMPAIGN_MANIFEST}" \ + --module-root "${MODULE_ROOT}" \ + --flow-root "${FLOW_ROOT}" \ + --report-dir "${REPORT_DIR}/${flow_id}" \ + --work-dir "${WORK_DIR}/${flow_id}" diff --git a/flows/sim/run.sh b/flows/sim/run.sh index 755f1ad..b778c96 100755 --- a/flows/sim/run.sh +++ b/flows/sim/run.sh @@ -11,8 +11,8 @@ case "${SIMULATOR:-}" in ;; esac -flow_report_dir="${REPORT_DIR}/${flow_name}" -flow_work_dir="${WORK_DIR}/${flow_name}" +flow_report_dir="${MOSAIC_SIM_REPORT_DIR:-${REPORT_DIR}/${flow_name}}" +flow_work_dir="${MOSAIC_SIM_WORK_DIR:-${WORK_DIR}/${flow_name}}" mkdir -p "${flow_report_dir}" "${flow_work_dir}" printf 'FAIL\n' > "${flow_report_dir}/status.txt" @@ -30,23 +30,36 @@ fi coverage_filelist_args=() coverage_enabled=0 +coverage_request=disabled if [[ -n "${COVERAGE_FILELIST:-}" ]]; then coverage_filelist_args=(-f "${COVERAGE_FILELIST}") - case "${SIM_COVERAGE:-enabled}" in - enabled) - coverage_enabled=1 - ;; - disabled) - ;; - *) - echo "SIM_COVERAGE must be enabled or disabled" >&2 - exit 2 - ;; - esac + coverage_request="${SIM_COVERAGE:-enabled}" fi +if [[ -n "${MOSAIC_SIM_FORCE_COVERAGE:-}" ]]; then + coverage_request="${MOSAIC_SIM_FORCE_COVERAGE}" +fi +case "${coverage_request}" in + enabled) + coverage_enabled=1 + ;; + disabled) + ;; + *) + echo "SIM_COVERAGE must be enabled or disabled" >&2 + exit 2 + ;; +esac case "${SIMULATOR}" in vcs) + parameter_args=() + if [[ "${MOSAIC_PROFILE_ACTIVE:-disabled}" == "enabled" ]]; then + mapfile -t parameter_args < <( + python3 "${PARAMETER_PROFILE_TOOL}" arguments \ + --parameters "${PROFILE_PARAMETERS_JSON}" --backend vcs \ + --top "${TB_TOP}" + ) + fi vcs_compile_coverage_args=() vcs_run_coverage_args=() if [[ "${coverage_enabled}" -eq 1 ]]; then @@ -63,12 +76,21 @@ case "${SIMULATOR}" in "${SIM_BIN:-vcs}" -full64 -sverilog -f "${TB_FILELIST}" \ "${property_args[@]}" "${assertion_args[@]}" \ "${coverage_filelist_args[@]}" \ - "${vcs_compile_coverage_args[@]}" -top "${TB_TOP}" \ + "${parameter_args[@]}" "${vcs_compile_coverage_args[@]}" \ + -top "${TB_TOP}" \ -o "${flow_work_dir}/simv" 2>&1 | tee "${flow_report_dir}/compile.log" simulation_binary="${flow_work_dir}/simv" simulation_args=("${vcs_run_coverage_args[@]}") ;; verilator) + parameter_args=() + if [[ "${MOSAIC_PROFILE_ACTIVE:-disabled}" == "enabled" ]]; then + mapfile -t parameter_args < <( + python3 "${PARAMETER_PROFILE_TOOL}" arguments \ + --parameters "${PROFILE_PARAMETERS_JSON}" --backend verilator \ + --top "${TB_TOP}" + ) + fi verilator_coverage_args=() simulation_args=() if [[ "${coverage_enabled}" -eq 1 ]]; then @@ -82,6 +104,7 @@ case "${SIMULATOR}" in "${property_args[@]}" "${assertion_args[@]}" \ "${coverage_filelist_args[@]}" \ "${verilator_coverage_args[@]}" \ + "${parameter_args[@]}" \ --top-module "${TB_TOP}" --Mdir "${flow_work_dir}/obj_dir" \ 2>&1 | tee "${flow_report_dir}/compile.log" simulation_binary="${flow_work_dir}/obj_dir/V${TB_TOP}" diff --git a/flows/slang/run.sh b/flows/slang/run.sh index 288db57..6b18e86 100755 --- a/flows/slang/run.sh +++ b/flows/slang/run.sh @@ -7,6 +7,15 @@ mkdir -p "${flow_report_dir}" "${flow_work_dir}" printf 'FAIL\n' > "${flow_report_dir}/status.txt" cd "${REPO_ROOT}" || exit 2 +parameter_args=() +if [[ "${MOSAIC_PROFILE_ACTIVE:-disabled}" == "enabled" ]]; then + mapfile -t parameter_args < <( + python3 "${PARAMETER_PROFILE_TOOL}" arguments \ + --parameters "${PROFILE_PARAMETERS_JSON}" --backend slang \ + --top "${DESIGN_TOP}" + ) +fi "${SLANG_CMD:-slang}" -f "${RTL_FILELIST}" --top "${DESIGN_TOP}" --single-unit \ + "${parameter_args[@]}" \ 2>&1 | tee "${flow_report_dir}/elaboration.log" printf 'PASS\n' > "${flow_report_dir}/status.txt" diff --git a/flows/static_intent/run.sh b/flows/static_intent/run.sh new file mode 100755 index 0000000..789359a --- /dev/null +++ b/flows/static_intent/run.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -euo pipefail + +flow_report_dir="${REPORT_DIR}/static_intent" +mkdir -p "${flow_report_dir}" +printf 'FAIL\n' >"${flow_report_dir}/status.txt" + +exec "${STATIC_INTENT_TOOL}" \ + --config "${STATIC_INTENT_CONFIG}" \ + --module-root "${MODULE_ROOT}" \ + --output-dir "${flow_report_dir}" diff --git a/flows/symbiyosys/run.sh b/flows/symbiyosys/run.sh index 561534f..7cc0b14 100755 --- a/flows/symbiyosys/run.sh +++ b/flows/symbiyosys/run.sh @@ -6,9 +6,19 @@ flow_work_dir="${WORK_DIR}/symbiyosys_formal" mkdir -p "${flow_report_dir}" "${flow_work_dir}" printf 'FAIL\n' > "${flow_report_dir}/status.txt" +# FORMAL_CONFIG is part of the module-owned exported environment contract. +# shellcheck disable=SC2153 +formal_config="${FORMAL_CONFIG}" +if [[ "${MOSAIC_PROFILE_ACTIVE:-disabled}" == "enabled" ]]; then + formal_config="${flow_report_dir}/profile.sby" + python3 "${PARAMETER_PROFILE_TOOL}" render-sby \ + --input "${FORMAL_CONFIG}" --output "${formal_config}" \ + --parameters "${PROFILE_PARAMETERS_JSON}" --top "${FORMAL_TOP}" +fi + cd "${REPO_ROOT}" || exit 2 "${SBY_CMD:-sby}" -f -d "${flow_work_dir}" \ - "${FORMAL_CONFIG}" 2>&1 | tee "${flow_report_dir}/formal.log" + "${formal_config}" 2>&1 | tee "${flow_report_dir}/formal.log" test -f "${flow_work_dir}/PASS" # Proof and cover reachability are separate SymbiYosys tasks because they use @@ -20,8 +30,15 @@ if [[ -n "${COVERAGE_FILELIST:-}" ]]; then fi coverage_work_dir="${WORK_DIR}/symbiyosys_coverage" + formal_cover_config="${FORMAL_COVER_CONFIG}" + if [[ "${MOSAIC_PROFILE_ACTIVE:-disabled}" == "enabled" ]]; then + formal_cover_config="${flow_report_dir}/coverage-profile.sby" + python3 "${PARAMETER_PROFILE_TOOL}" render-sby \ + --input "${FORMAL_COVER_CONFIG}" --output "${formal_cover_config}" \ + --parameters "${PROFILE_PARAMETERS_JSON}" --top "${FORMAL_TOP}" + fi "${SBY_CMD:-sby}" -f -d "${coverage_work_dir}" \ - "${FORMAL_COVER_CONFIG}" 2>&1 | tee "${flow_report_dir}/coverage.log" + "${formal_cover_config}" 2>&1 | tee "${flow_report_dir}/coverage.log" test -f "${coverage_work_dir}/PASS" fi diff --git a/flows/synthesis/run.sh b/flows/synthesis/run.sh index f4826c7..f88d762 100755 --- a/flows/synthesis/run.sh +++ b/flows/synthesis/run.sh @@ -1,5 +1,15 @@ #!/usr/bin/env bash source "$(dirname "$0")/../common/env.sh" +export PROFILE_DC_PARAMETERS="" +if [[ "${MOSAIC_PROFILE_ACTIVE:-disabled}" == "enabled" ]]; then + PROFILE_DC_PARAMETERS="$( + python3 "${PARAMETER_PROFILE_TOOL}" arguments \ + --parameters "${PROFILE_PARAMETERS_JSON}" --backend dc \ + --top "${DESIGN_TOP}" + )" + export PROFILE_DC_PARAMETERS +fi + run_and_record synopsys_synthesis "${SYNTH_BIN:-dc_shell}" -f \ "${FLOW_ROOT}/flows/synthesis/run.tcl" diff --git a/flows/synthesis/run.tcl b/flows/synthesis/run.tcl index af2c7af..4b9fa42 100644 --- a/flows/synthesis/run.tcl +++ b/flows/synthesis/run.tcl @@ -4,7 +4,11 @@ if {[info exists env(TECH_SETUP_TCL)] && $env(TECH_SETUP_TCL) ne ""} { set_svf $env(WORK_DIR)/synopsys_synthesis/$env(DESIGN_TOP).svf analyze -format sverilog -vcs "-f $env(RTL_FILELIST)" -elaborate $env(DESIGN_TOP) +if {[info exists env(PROFILE_DC_PARAMETERS)] && $env(PROFILE_DC_PARAMETERS) ne ""} { + elaborate $env(DESIGN_TOP) -parameters $env(PROFILE_DC_PARAMETERS) +} else { + elaborate $env(DESIGN_TOP) +} current_design $env(DESIGN_TOP) link source $env(CONSTRAINT_DIR)/timing.sdc diff --git a/flows/verible/run_format.sh b/flows/verible/run_format.sh index b7d2bde..574ba10 100755 --- a/flows/verible/run_format.sh +++ b/flows/verible/run_format.sh @@ -5,13 +5,31 @@ flow_report_dir="${REPORT_DIR}/verible_format" mkdir -p "${flow_report_dir}" printf 'FAIL\n' > "${flow_report_dir}/status.txt" -mapfile -t sources < <(find rtl verif -type f \( -name '*.sv' -o -name '*.svh' \) -print | sort) cd "${REPO_ROOT}" || exit 2 +read -r -a format_paths <<< "${VERIBLE_FORMAT_PATHS:-rtl verif}" +read -r -a format_args <<< "${VERIBLE_FORMAT_ARGS:-}" +if [[ "${#format_paths[@]}" -eq 0 ]]; then + echo "VERIBLE_FORMAT_PATHS must select at least one source path" >&2 + exit 2 +fi +for format_path in "${format_paths[@]}"; do + if [[ ! -e "${format_path}" ]]; then + echo "Verible format path does not exist: ${format_path}" >&2 + exit 2 + fi +done +mapfile -t sources < <( + find "${format_paths[@]}" -type f \( -name '*.sv' -o -name '*.svh' \) -print | sort -u +) +if [[ "${#sources[@]}" -eq 0 ]]; then + echo "VERIBLE_FORMAT_PATHS did not select any SystemVerilog sources" >&2 + exit 2 +fi : >"${flow_report_dir}/format.log" for source in "${sources[@]}"; do if ! diff -u "${source}" <("${VERIBLE_FORMAT_CMD:-verible-verilog-format}" \ - --verify_convergence --failsafe_success=false "${source}") \ + "${format_args[@]}" --verify_convergence --failsafe_success=false "${source}") \ >>"${flow_report_dir}/format.log"; then echo "Formatting differs: ${source}" >&2 exit 1 diff --git a/flows/verilator_lint/run.sh b/flows/verilator_lint/run.sh index c072895..5faa493 100755 --- a/flows/verilator_lint/run.sh +++ b/flows/verilator_lint/run.sh @@ -6,8 +6,17 @@ flow_work_dir="${WORK_DIR}/verilator_lint" mkdir -p "${flow_report_dir}" "${flow_work_dir}" printf 'FAIL\n' > "${flow_report_dir}/status.txt" +parameter_args=() +if [[ "${MOSAIC_PROFILE_ACTIVE:-disabled}" == "enabled" ]]; then + mapfile -t parameter_args < <( + python3 "${PARAMETER_PROFILE_TOOL}" arguments \ + --parameters "${PROFILE_PARAMETERS_JSON}" --backend verilator \ + --top "${DESIGN_TOP}" + ) +fi "${VERILATOR_CMD:-verilator}" --lint-only --sv --Wall \ --top-module "${DESIGN_TOP}" "${VERILATOR_WAIVER_FILE}" \ - -f "${RTL_FILELIST}" 2>&1 | tee "${flow_report_dir}/lint.log" + "${parameter_args[@]}" -f "${RTL_FILELIST}" \ + 2>&1 | tee "${flow_report_dir}/lint.log" printf 'PASS\n' > "${flow_report_dir}/status.txt" diff --git a/flows/yosys_synthesis/run.sh b/flows/yosys_synthesis/run.sh index ea27803..5400cd6 100755 --- a/flows/yosys_synthesis/run.sh +++ b/flows/yosys_synthesis/run.sh @@ -24,9 +24,19 @@ while IFS= read -r filelist_entry || [[ -n "${filelist_entry}" ]]; do esac done < "${RTL_FILELIST}" +parameter_commands="" +if [[ "${MOSAIC_PROFILE_ACTIVE:-disabled}" == "enabled" ]]; then + parameter_commands="$( + python3 "${PARAMETER_PROFILE_TOOL}" arguments \ + --parameters "${PROFILE_PARAMETERS_JSON}" --backend yosys \ + --top "${DESIGN_TOP}" + )" +fi + cd "${REPO_ROOT}" || exit 2 "${YOSYS_CMD:-yosys}" -l "${flow_report_dir}/synthesis.log" -p " read_verilog ${read_verilog_args}; + ${parameter_commands} hierarchy -check -top ${DESIGN_TOP}; proc; opt; diff --git a/mk/module.mk b/mk/module.mk index f26477f..5d0a6f6 100644 --- a/mk/module.mk +++ b/mk/module.mk @@ -2,7 +2,56 @@ export CDC_TOOL ?= vc export FORCE_FLOW ?= 0 include $(FLOW_ROOT)/config/flows.mk --include $(MODULE_ROOT)/config/flows.mk +MODULE_DESIGN_CONFIG ?= $(MODULE_ROOT)/config/design.mk +MODULE_FLOW_CONFIG ?= $(MODULE_ROOT)/config/flows.mk +-include $(MODULE_FLOW_CONFIG) + +PARAMETER_PROFILE_TOOL ?= $(FLOW_ROOT)/ci/parameter_profiles.py +PARAMETER_PROFILE_MANIFEST ?= $(if $(MODULE),$(MODULE_ROOT)/config/parameter-profiles/$(MODULE).json,$(MODULE_ROOT)/config/parameter-profiles.json) +export PARAMETER_PROFILE_TOOL +MOSAIC_PARAMETER_PROFILES := $(if $(wildcard $(PARAMETER_PROFILE_MANIFEST)),enabled,disabled) +PROFILE_JOBS ?= $(if $(JOBS),$(JOBS),0) +PROFILE_TARGET ?= open-source +export PROFILE_PARAMETERS_JSON := {} +export PROFILE_APPLICABLE_FLOWS := +export MOSAIC_PROFILE_ACTIVE := disabled + +ifeq ($(MOSAIC_PARAMETER_PROFILES),enabled) +ifneq ($(strip $(PROFILE)),) +MOSAIC_PROFILE_VALIDATION_ERROR := $(shell python3 "$(PARAMETER_PROFILE_TOOL)" validate --quiet --manifest "$(PARAMETER_PROFILE_MANIFEST)" 2>&1 && python3 "$(PARAMETER_PROFILE_TOOL)" get --manifest "$(PARAMETER_PROFILE_MANIFEST)" --selected "$(PROFILE)" --field flows >/dev/null 2>&1 || echo "Invalid or unknown PROFILE '$(PROFILE)'; run make profile-manifest-check") +ifneq ($(strip $(MOSAIC_PROFILE_VALIDATION_ERROR)),) +$(error $(MOSAIC_PROFILE_VALIDATION_ERROR)) +endif + +export PROFILE_PARAMETERS_JSON := $(shell python3 "$(PARAMETER_PROFILE_TOOL)" get --manifest "$(PARAMETER_PROFILE_MANIFEST)" --selected "$(PROFILE)" --field parameters) +export PROFILE_APPLICABLE_FLOWS := $(shell python3 "$(PARAMETER_PROFILE_TOOL)" get --manifest "$(PARAMETER_PROFILE_MANIFEST)" --selected "$(PROFILE)" --field flows) +MOSAIC_PROFILE_DESIGN_TOP := $(shell python3 "$(PARAMETER_PROFILE_TOOL)" get --manifest "$(PARAMETER_PROFILE_MANIFEST)" --selected "$(PROFILE)" --field design) +MOSAIC_PROFILE_TESTBENCH_TOP := $(shell python3 "$(PARAMETER_PROFILE_TOOL)" get --manifest "$(PARAMETER_PROFILE_MANIFEST)" --selected "$(PROFILE)" --field testbench) +MOSAIC_PROFILE_FORMAL_TOP := $(shell python3 "$(PARAMETER_PROFILE_TOOL)" get --manifest "$(PARAMETER_PROFILE_MANIFEST)" --selected "$(PROFILE)" --field formal) +MOSAIC_PROFILE_PYUVM_TOP := $(shell python3 "$(PARAMETER_PROFILE_TOOL)" get --manifest "$(PARAMETER_PROFILE_MANIFEST)" --selected "$(PROFILE)" --field pyuvm) + +ifneq ($(strip $(MOSAIC_PROFILE_DESIGN_TOP)),) +override DESIGN_TOP := $(MOSAIC_PROFILE_DESIGN_TOP) +endif +ifneq ($(strip $(MOSAIC_PROFILE_TESTBENCH_TOP)),) +override TB_TOP := $(MOSAIC_PROFILE_TESTBENCH_TOP) +endif +ifneq ($(strip $(MOSAIC_PROFILE_FORMAL_TOP)),) +override FORMAL_TOP := $(MOSAIC_PROFILE_FORMAL_TOP) +endif +ifneq ($(strip $(MOSAIC_PROFILE_PYUVM_TOP)),) +override PYUVM_TOP := $(MOSAIC_PROFILE_PYUVM_TOP) +endif +export DESIGN_TOP TB_TOP FORMAL_TOP PYUVM_TOP + +PROFILE_REPORT_ROOT := $(REPORT_DIR) +PROFILE_WORK_ROOT := $(WORK_DIR) +override REPORT_DIR := $(PROFILE_REPORT_ROOT)/$(PROFILE) +override WORK_DIR := $(PROFILE_WORK_ROOT)/$(PROFILE) +export REPORT_DIR WORK_DIR +export MOSAIC_PROFILE_ACTIVE := enabled +endif +endif mosaic_flow_state_is_valid = $(and $(filter 1,$(words $(1))),$(filter $(1),enabled disabled)) MOSAIC_INVALID_FLOW_STATES := $(strip $(foreach flow,$(MOSAIC_FLOW_IDS),$(if $(call mosaic_flow_state_is_valid,$(FLOW_$(flow))),,$(flow)=$(FLOW_$(flow))))) @@ -11,15 +60,103 @@ $(error Flow states must be 'enabled' or 'disabled': $(MOSAIC_INVALID_FLOW_STATE endif MOSAIC_EXPLICIT_DISABLED_FLOWS := $(DISABLED_FLOWS) -override DISABLED_FLOWS := $(sort $(MOSAIC_EXPLICIT_DISABLED_FLOWS) $(foreach flow,$(MOSAIC_FLOW_IDS),$(if $(filter disabled,$(FLOW_$(flow))),$(flow)))) +MOSAIC_MODULE_DISABLED_FLOWS := $(sort $(filter $(MOSAIC_FLOW_IDS),$(MOSAIC_EXPLICIT_DISABLED_FLOWS)) $(foreach flow,$(MOSAIC_FLOW_IDS),$(if $(filter disabled,$(FLOW_$(flow))),$(flow)))) +MOSAIC_PROFILE_POLICY_CONFLICTS := $(filter $(MOSAIC_MODULE_DISABLED_FLOWS),$(PROFILE_APPLICABLE_FLOWS)) +ifneq ($(MOSAIC_PROFILE_POLICY_CONFLICTS),) +$(error PROFILE '$(PROFILE)' requires flows disabled by module policy: $(MOSAIC_PROFILE_POLICY_CONFLICTS)) +endif +MOSAIC_PROFILE_DISABLED_FLOWS := $(if $(PROFILE_APPLICABLE_FLOWS),$(filter-out $(PROFILE_APPLICABLE_FLOWS),$(MOSAIC_FLOW_IDS))) +override DISABLED_FLOWS := $(sort $(MOSAIC_EXPLICIT_DISABLED_FLOWS) $(MOSAIC_MODULE_DISABLED_FLOWS) $(MOSAIC_PROFILE_DISABLED_FLOWS)) export DISABLED_FLOWS export MOSAIC_FLOW_IDS export $(foreach flow,$(MOSAIC_FLOW_IDS),FLOW_$(flow) FLOW_DEPENDENCIES_$(flow)) FLOW_RUNNER := $(FLOW_ROOT)/ci/run_flow.sh -OPEN_SOURCE_TARGETS := open-source open-style-lint open-format-check open-elaborate open-lint open-waiver-draft open-synth open-formal open-equivalence open-sim open-pyuvm open-quality-gate -OPEN_FLOW_TARGETS := open-style-lint open-format-check open-elaborate open-lint open-synth open-formal open-equivalence open-sim open-pyuvm +# RELEASE_MANIFEST_TOOL: Methodology-owned generator and validator. +RELEASE_MANIFEST_TOOL ?= $(FLOW_ROOT)/ci/release_manifest.py +# RELEASE_MODULE_NAME: Stable module identity written into release evidence. +RELEASE_MODULE_NAME ?= $(if $(MODULE),$(MODULE),$(DESIGN_TOP)) +# MODULE_REVISION: Exact 40-digit module commit. Empty permits local Git fallback. +MODULE_REVISION ?= +# METHODOLOGY_REVISION: Exact 40-digit mosaic-flow commit. Empty permits local Git fallback. +METHODOLOGY_REVISION ?= +# RELEASE_EXECUTION_CONTEXT: Output namespace and execution label, such as native or container. +RELEASE_EXECUTION_CONTEXT ?= native +# RELEASE_ALLOW_DIRTY: Permit a dirty module checkout only for local diagnostics. +RELEASE_ALLOW_DIRTY ?= disabled +# RELEASE_MODULE_DIRTY: Explicit true or false for a packaged module without Git metadata. +RELEASE_MODULE_DIRTY ?= +# RELEASE_METHODOLOGY_DIRTY: Explicit true or false for packaged mosaic-flow source. +RELEASE_METHODOLOGY_DIRTY ?= +# RELEASE_TECHNOLOGY: Human-readable technology or technology-independent context. +RELEASE_TECHNOLOGY ?= technology-independent +# RELEASE_TECHNOLOGY_METADATA_JSON: Structured technology, PDK, library, and corner details. +RELEASE_TECHNOLOGY_METADATA_JSON ?= {} +# RELEASE_METADATA_JSON: Arbitrary module-owned deterministic release metadata. +RELEASE_METADATA_JSON ?= {} +# RELEASE_EXECUTION_METADATA_JSON: Arbitrary volatile runner or container metadata. +RELEASE_EXECUTION_METADATA_JSON ?= {} +# RELEASE_ADDITIONAL_TOOLS_JSON: Additional tool commands and associated canonical flows. +RELEASE_ADDITIONAL_TOOLS_JSON ?= [] +# RELEASE_COVERAGE_EVIDENCE_JSON: Additional native or functional coverage records. +RELEASE_COVERAGE_EVIDENCE_JSON ?= [] +# RELEASE_SUPPLEMENTAL_GATES: Module-owned gate IDs with reports//status.txt evidence. +RELEASE_SUPPLEMENTAL_GATES ?= +# RELEASE_ADDITIONAL_INPUTS: Required files or directories added to the hashed input set. +RELEASE_ADDITIONAL_INPUTS ?= +# RELEASE_ADDITIONAL_EVIDENCE: Required generated files indexed by path and SHA-256. +RELEASE_ADDITIONAL_EVIDENCE ?= +# RELEASE_MANIFEST_DIR: Context-specific output directory below the selected report root. +RELEASE_MANIFEST_DIR ?= $(REPORT_DIR)/release_manifest/$(RELEASE_EXECUTION_CONTEXT) + +RELEASE_STANDARD_INPUTS := \ + $(MODULE_DESIGN_CONFIG) \ + $(MODULE_FLOW_CONFIG) \ + $(if $(wildcard $(MODULE_MANIFEST)),$(MODULE_MANIFEST)) \ + $(if $(wildcard $(PARAMETER_PROFILE_MANIFEST)),$(PARAMETER_PROFILE_MANIFEST)) \ + $(VERILATOR_WAIVER_FILE) \ + $(VERIBLE_WAIVER_FILE) \ + $(VERIBLE_RULES_FILE) \ + $(FORMAL_CONFIG) \ + $(FORMAL_COVER_CONFIG) \ + $(FORMAL_COVERAGE_CONFIG) \ + $(if $(filter enabled,$(FLOW_coverage_qualification)),$(COVERAGE_QUALIFICATION_POLICY)) \ + $(if $(filter enabled,$(FLOW_negative_qualification) $(FLOW_four_state_qualification)),$(QUALIFICATION_CAMPAIGN_MANIFEST)) \ + $(if $(filter enabled,$(FLOW_static_intent)),$(STATIC_INTENT_CONFIG)) \ + $(EQUIVALENCE_CONFIG) \ + $(OPENROAD_CONFIG) \ + $(if $(filter openroad,$(DISABLED_FLOWS)),,$(if $(filter enabled,$(FLOW_openroad)),$(OPENROAD_EVIDENCE_POLICY))) \ + $(SYNTHESIS_CONSTRAINT_FILE) \ + $(ASYNC_SYNTHESIS_CONSTRAINT_FILE) \ + $(OPENROAD_CONSTRAINT_FILE) \ + $(CDC_CONFIG) \ + $(DFT_CONFIG) \ + $(UPF_CONFIG) \ + $(if $(SYNTHESIS_CONSTRAINT_FILE),,$(CONSTRAINT_DIR)) \ + $(if $(PYUVM_TEST_MODULE),$(PYUVM_TEST_PATH)) +RELEASE_FILELISTS := $(sort $(strip \ + $(RTL_FILELIST) \ + $(TB_FILELIST) \ + $(FORMAL_FILELIST) \ + $(PROPERTY_FILELIST) \ + $(ASSERTION_FILELIST) \ + $(COVERAGE_FILELIST) \ + $(PYUVM_FILELIST))) +RELEASE_INPUT_FILES := $(sort $(strip $(RELEASE_STANDARD_INPUTS) $(RELEASE_ADDITIONAL_INPUTS))) +RELEASE_STANDARD_EVIDENCE := $(if $(filter openroad,$(DISABLED_FLOWS)),,$(if $(filter enabled,$(FLOW_openroad)),$(REPORT_DIR)/openroad/evidence.json)) + +export RELEASE_MANIFEST_TOOL RELEASE_MODULE_NAME MODULE_REVISION METHODOLOGY_REVISION +export RELEASE_EXECUTION_CONTEXT RELEASE_ALLOW_DIRTY RELEASE_TECHNOLOGY +export RELEASE_MODULE_DIRTY RELEASE_METHODOLOGY_DIRTY +export RELEASE_TECHNOLOGY_METADATA_JSON RELEASE_METADATA_JSON +export RELEASE_EXECUTION_METADATA_JSON RELEASE_ADDITIONAL_TOOLS_JSON +export RELEASE_COVERAGE_EVIDENCE_JSON RELEASE_SUPPLEMENTAL_GATES +export RELEASE_ADDITIONAL_INPUTS RELEASE_ADDITIONAL_EVIDENCE RELEASE_MANIFEST_DIR +export RELEASE_FILELISTS RELEASE_INPUT_FILES RELEASE_STANDARD_EVIDENCE + +OPEN_SOURCE_TARGETS := open-source open-style-lint open-format-check open-elaborate open-lint open-waiver-draft open-synth open-formal open-equivalence open-sim open-pyuvm open-coverage open-negative open-four-state open-static-intent open-quality-gate +OPEN_FLOW_TARGETS := open-style-lint open-format-check open-elaborate open-lint open-synth open-formal open-equivalence open-sim open-pyuvm open-coverage open-negative open-four-state open-static-intent FLOW_TARGET_verible_lint := open-style-lint FLOW_TARGET_verible_format := open-format-check @@ -30,6 +167,10 @@ FLOW_TARGET_symbiyosys_formal := open-formal FLOW_TARGET_eqy_equivalence := open-equivalence FLOW_TARGET_verilator_sim := open-sim FLOW_TARGET_pyuvm_open_source := open-pyuvm +FLOW_TARGET_coverage_qualification := open-coverage +FLOW_TARGET_negative_qualification := open-negative +FLOW_TARGET_four_state_qualification := open-four-state +FLOW_TARGET_static_intent := open-static-intent FLOW_TARGET_openroad := open-physical FLOW_TARGET_vcs_sim := synopsys-sim FLOW_TARGET_pyuvm_commercial := commercial-pyuvm @@ -49,13 +190,108 @@ $(FLOW_TARGET_$(1)): $(foreach dependency,$(FLOW_DEPENDENCIES_$(1)),$(FLOW_TARGE endef $(foreach flow,$(MOSAIC_FLOW_IDS),$(eval $(call mosaic_add_flow_dependencies,$(flow)))) -.PHONY: help flow-config-check setup-open-source $(OPEN_SOURCE_TARGETS) open-physical commercial-pyuvm synopsys-all synopsys-check-env synopsys-sim synopsys-lint synopsys-cdc synopsys-dft synopsys-lp synopsys-static synopsys-synth synopsys-sta synopsys-power synopsys-quality-gate clean +.PHONY: help mosaic-module-selection-check mosaic-profile-selection-check profile-manifest-check profile-list profile-matrix profile-evidence flow-config-check setup-open-source release-manifest release-manifest-validate $(OPEN_SOURCE_TARGETS) open-physical commercial-pyuvm synopsys-all synopsys-check-env synopsys-sim synopsys-lint synopsys-cdc synopsys-dft synopsys-lp synopsys-static synopsys-synth synopsys-sta synopsys-power synopsys-quality-gate clean help: - @sed -n 's/^## //p' "$(FLOW_ROOT)/mk/module.mk" + @sed -n 's/^## //p' "$(FLOW_ROOT)/mk/module.mk" "$(FLOW_ROOT)/mk/project.mk" + +mosaic-module-selection-check: + @if [[ "$(MOSAIC_MULTI_MODULE)" == "enabled" && -z "$(MODULE)" ]]; then \ + echo "This is a multi-module project; select MODULE= or use all-modules" >&2; \ + exit 2; \ + fi + +profile-manifest-check: + @if [[ "$(MOSAIC_PARAMETER_PROFILES)" == "enabled" ]]; then \ + python3 "$(PARAMETER_PROFILE_TOOL)" validate \ + --manifest "$(PARAMETER_PROFILE_MANIFEST)"; \ + else \ + echo "No parameter-profile manifest; the unqualified default configuration is active"; \ + fi + +profile-list: + @if [[ "$(MOSAIC_PARAMETER_PROFILES)" == "enabled" ]]; then \ + python3 "$(PARAMETER_PROFILE_TOOL)" list \ + --manifest "$(PARAMETER_PROFILE_MANIFEST)"; \ + else \ + printf 'default\n'; \ + fi + +profile-matrix: + @if [[ "$(MOSAIC_PARAMETER_PROFILES)" == "enabled" ]]; then \ + python3 "$(PARAMETER_PROFILE_TOOL)" matrix \ + --manifest "$(PARAMETER_PROFILE_MANIFEST)" \ + --module "$(if $(MODULE),$(MODULE),$(DESIGN_TOP))"; \ + else \ + printf '{"include":[{"job_name":"%s--default","module":"%s","parameters":{},"profile":"default"}]}\n' \ + "$(if $(MODULE),$(MODULE),$(DESIGN_TOP))" \ + "$(if $(MODULE),$(MODULE),$(DESIGN_TOP))"; \ + fi + +mosaic-profile-selection-check: + @if [[ "$(MOSAIC_PARAMETER_PROFILES)" == "enabled" && -z "$(PROFILE)" ]]; then \ + echo "This module declares parameter profiles; select PROFILE= or use all-profiles" >&2; \ + exit 2; \ + fi; \ + if [[ "$(MOSAIC_PARAMETER_PROFILES)" == "disabled" && -n "$(PROFILE)" && "$(PROFILE)" != "default" ]]; then \ + echo "PROFILE=$(PROFILE) requires a parameter-profile manifest" >&2; \ + exit 2; \ + fi + +profile-evidence: mosaic-profile-selection-check + @if [[ "$(MOSAIC_PROFILE_ACTIVE)" == "enabled" ]]; then \ + python3 "$(PARAMETER_PROFILE_TOOL)" evidence \ + --manifest "$(PARAMETER_PROFILE_MANIFEST)" --selected "$(PROFILE)" \ + --output "$(REPORT_DIR)/parameter-profile.json" \ + --design-top "$(DESIGN_TOP)" --testbench-top "$(TB_TOP)" \ + --formal-top "$(FORMAL_TOP)" --pyuvm-top "$(PYUVM_TOP)"; \ + fi + +## release-manifest Generate and validate context-specific release evidence +release-manifest: flow-config-check + @python3 "$(RELEASE_MANIFEST_TOOL)" generate \ + --module-root "$(MODULE_ROOT)" \ + --flow-root "$(FLOW_ROOT)" \ + --report-dir "$(REPORT_DIR)" \ + --output-dir "$(RELEASE_MANIFEST_DIR)" + +## release-manifest-validate Validate an existing context-specific manifest +release-manifest-validate: mosaic-module-selection-check + @python3 "$(RELEASE_MANIFEST_TOOL)" validate \ + --manifest "$(RELEASE_MANIFEST_DIR)/manifest.json" + +ifeq ($(MOSAIC_PARAMETER_PROFILES),enabled) +.PHONY: all-profiles + +## all-profiles Run PROFILE_TARGET for every declared parameter profile +all-profiles: profile-manifest-check + +@if [[ ! "$(PROFILE_JOBS)" =~ ^[0-9]+$$ ]]; then \ + echo "PROFILE_JOBS must be a nonnegative integer" >&2; \ + exit 2; \ + fi; \ + if [[ ! "$(PROFILE_TARGET)" =~ ^[A-Za-z0-9_.-]+$$ || "$(PROFILE_TARGET)" == "all-profiles" ]]; then \ + echo "PROFILE_TARGET must name one non-recursive Make target" >&2; \ + exit 2; \ + fi; \ + set +e; \ + python3 "$(PARAMETER_PROFILE_TOOL)" list --manifest "$(PARAMETER_PROFILE_MANIFEST)" | \ + xargs --no-run-if-empty --max-procs="$(PROFILE_JOBS)" --replace={} \ + $(MAKE) --no-print-directory -f "$(abspath $(firstword $(MAKEFILE_LIST)))" PROFILE={} "$(PROFILE_TARGET)"; \ + aggregate_status=$$?; \ + python3 "$(PARAMETER_PROFILE_TOOL)" summary \ + --manifest "$(PARAMETER_PROFILE_MANIFEST)" --report-root "$(REPORT_DIR)" \ + --output "$(REPORT_DIR)/parameter-profile-summary.json"; \ + summary_status=$$?; \ + if [[ "$${summary_status}" -ne 0 ]]; then exit "$${summary_status}"; fi; \ + exit "$${aggregate_status}" + +ifneq ($(filter $(PROFILE_TARGET),$(OPEN_SOURCE_TARGETS)),) +all-profiles: setup-open-source +endif +endif ## flow-config-check Validate and display the project flow selection -flow-config-check: +flow-config-check: mosaic-module-selection-check profile-evidence @"$(FLOW_ROOT)/ci/check_flow_config.sh" ## setup-open-source Install missing pinned open-source tools in the user cache @@ -63,7 +299,8 @@ setup-open-source: @"$(FLOW_ROOT)/ci/setup_open_source_tools.sh" $(MOSAIC_FLOW_TARGETS): | flow-config-check -$(OPEN_FLOW_TARGETS) open-waiver-draft: | setup-open-source +$(OPEN_FLOW_TARGETS): | setup-open-source +open-waiver-draft: mosaic-module-selection-check | setup-open-source ## open-source Run every open-source CI check and its quality gate open-source: open-quality-gate @@ -108,6 +345,22 @@ open-sim: open-pyuvm: @PYUVM_SIMULATOR="$(PYUVM_OPEN_SIMULATOR)" "$(FLOW_RUNNER)" pyuvm_open_source "$(FLOW_ROOT)/flows/pyuvm/run.sh" pyuvm_open_source +## open-coverage Qualify native HDL and optional formal coverage evidence +open-coverage: + @"$(FLOW_RUNNER)" coverage_qualification "$(FLOW_ROOT)/flows/coverage/run.sh" + +## open-negative Prove module-owned faults and invalid configurations are detected +open-negative: + @"$(FLOW_RUNNER)" negative_qualification "$(FLOW_ROOT)/flows/qualification/run.sh" negative + +## open-four-state Detect declared X/Z controls with pinned Icarus simulation +open-four-state: + @"$(FLOW_RUNNER)" four_state_qualification "$(FLOW_ROOT)/flows/qualification/run.sh" four_state + +## open-static-intent Validate portable SDC and UPF intent without EDA licenses +open-static-intent: + @"$(FLOW_RUNNER)" static_intent "$(FLOW_ROOT)/flows/static_intent/run.sh" + ## open-quality-gate Validate all open-source results open-quality-gate: $(OPEN_FLOW_TARGETS) @"$(FLOW_ROOT)/ci/open_source_quality_gate.sh" diff --git a/mk/project.mk b/mk/project.mk new file mode 100644 index 0000000..96e4925 --- /dev/null +++ b/mk/project.mk @@ -0,0 +1,99 @@ +# Bootstrap either the legacy single-module layout or a manifest-based project. +ifndef MODULE_ROOT +$(error MODULE_ROOT must identify the consuming repository root) +endif +ifndef FLOW_ROOT +$(error FLOW_ROOT must identify the mosaic-flow checkout) +endif + +export MODULE_MANIFEST ?= $(MODULE_ROOT)/config/modules.json +export MOSAIC_MODULE_MANIFEST_TOOL ?= $(FLOW_ROOT)/ci/module_manifest.py +export MOSAIC_MULTI_MODULE := $(if $(wildcard $(MODULE_MANIFEST)),enabled,disabled) +MOSAIC_PROJECT_MAKEFILE ?= $(abspath $(firstword $(MAKEFILE_LIST))) +MODULE_JOBS ?= $(if $(JOBS),$(JOBS),0) +TARGET ?= open-source + +# Module profiles can use these helpers after assigning DESIGN_TOP. A named +# input takes precedence while the repository-wide file remains the fallback. +mosaic_resolve_filelist = $(or $(wildcard $(MODULE_ROOT)/filelists/$(DESIGN_TOP).$(1)),$(MODULE_ROOT)/filelists/$(1)) +mosaic_resolve_flow_config = $(or $(wildcard $(MODULE_ROOT)/flows/$(1)/$(DESIGN_TOP).$(2)),$(MODULE_ROOT)/flows/$(1)/$(2)) + +ifeq ($(MOSAIC_MULTI_MODULE),enabled) +ifneq ($(strip $(MODULE)),) +MOSAIC_MODULE_VALIDATION_ERROR := $(shell python3 "$(MOSAIC_MODULE_MANIFEST_TOOL)" validate --quiet --manifest "$(MODULE_MANIFEST)" --module-root "$(MODULE_ROOT)" --selected "$(MODULE)" 2>&1) +ifneq ($(strip $(MOSAIC_MODULE_VALIDATION_ERROR)),) +$(error $(MOSAIC_MODULE_VALIDATION_ERROR)) +endif + +export MODULE_DESIGN_CONFIG ?= $(MODULE_ROOT)/config/modules/$(MODULE).mk +export MODULE_FLOW_CONFIG ?= $(MODULE_ROOT)/config/modules/$(MODULE)-flows.mk +include $(MODULE_DESIGN_CONFIG) + +# A module may override these paths, but isolation is the safe default. +export REPORT_DIR ?= $(MODULE_ROOT)/reports/$(MODULE) +export WORK_DIR ?= $(MODULE_ROOT)/work/$(MODULE) +else +# Administrative targets can inspect or dispatch the manifest without choosing +# a design. Flow targets reject the missing selection before launching tools, +# and clean.sh uses repository-wide fallbacks without exporting them to child +# module invocations. +endif +else +export MODULE_DESIGN_CONFIG ?= $(MODULE_ROOT)/config/design.mk +export MODULE_FLOW_CONFIG ?= $(MODULE_ROOT)/config/flows.mk +include $(MODULE_DESIGN_CONFIG) +endif + +include $(FLOW_ROOT)/config/tools.mk +include $(FLOW_ROOT)/mk/module.mk + +ifeq ($(MOSAIC_MULTI_MODULE),enabled) +.PHONY: module-manifest-check module-list module-matrix module-profile-matrix all-modules + +## module-manifest-check Validate a multi-module project registry and layout +module-manifest-check: + @python3 "$(MOSAIC_MODULE_MANIFEST_TOOL)" validate \ + --manifest "$(MODULE_MANIFEST)" --module-root "$(MODULE_ROOT)" + +## module-list Print registered module names, one per line +module-list: + @python3 "$(MOSAIC_MODULE_MANIFEST_TOOL)" list \ + --manifest "$(MODULE_MANIFEST)" --module-root "$(MODULE_ROOT)" + +## module-matrix Emit the registered modules as a GitHub Actions JSON matrix +module-matrix: + @python3 "$(MOSAIC_MODULE_MANIFEST_TOOL)" matrix \ + --manifest "$(MODULE_MANIFEST)" --module-root "$(MODULE_ROOT)" + +## module-profile-matrix Emit every module/profile pair as a GitHub Actions matrix +module-profile-matrix: + +@set -o pipefail; \ + python3 "$(MOSAIC_MODULE_MANIFEST_TOOL)" list \ + --manifest "$(MODULE_MANIFEST)" --module-root "$(MODULE_ROOT)" | \ + while IFS= read -r module_name; do \ + $(MAKE) --no-print-directory --silent -f "$(MOSAIC_PROJECT_MAKEFILE)" \ + MODULE="$${module_name}" profile-matrix; \ + done | python3 "$(FLOW_ROOT)/ci/parameter_profiles.py" combine-matrices + +## all-modules Run TARGET for every registered module with bounded parallelism +all-modules: module-manifest-check + +@if [[ ! "$(MODULE_JOBS)" =~ ^[0-9]+$$ ]]; then \ + echo "MODULE_JOBS must be a nonnegative integer" >&2; \ + exit 2; \ + fi; \ + if [[ ! "$(TARGET)" =~ ^[A-Za-z0-9_.-]+$$ || "$(TARGET)" == "all-modules" ]]; then \ + echo "TARGET must name one non-recursive Make target" >&2; \ + exit 2; \ + fi; \ + python3 "$(MOSAIC_MODULE_MANIFEST_TOOL)" list \ + --manifest "$(MODULE_MANIFEST)" --module-root "$(MODULE_ROOT)" | \ + xargs --no-run-if-empty --max-procs="$(MODULE_JOBS)" --replace={} \ + $(MAKE) --no-print-directory -f "$(MOSAIC_PROJECT_MAKEFILE)" MODULE={} "$(TARGET)" + +# Populate the shared tool cache once before open-source module jobs run in +# parallel. Child invocations retain their normal setup prerequisite as a +# defensive check. +ifneq ($(filter $(TARGET),$(OPEN_SOURCE_TARGETS)),) +all-modules: setup-open-source +endif +endif diff --git a/schemas/coverage-policy-v1.schema.json b/schemas/coverage-policy-v1.schema.json new file mode 100644 index 0000000..e4074df --- /dev/null +++ b/schemas/coverage-policy-v1.schema.json @@ -0,0 +1,71 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://mosaic.dev/schemas/coverage-policy-v1.schema.json", + "title": "MOSAIC coverage qualification policy", + "type": "object", + "additionalProperties": false, + "required": ["schema", "thresholds", "coverpoints", "exclusions", "formal"], + "properties": { + "schema": {"const": "mosaic-coverage-policy-v1"}, + "scope": { + "type": "object", + "additionalProperties": false, + "properties": { + "include": {"type": "array", "items": {"type": "string", "minLength": 1}, "minItems": 1}, + "exclude": {"type": "array", "items": {"type": "string", "minLength": 1}} + } + }, + "thresholds": { + "type": "object", + "additionalProperties": false, + "properties": { + "line": {"type": "number", "minimum": 0, "maximum": 100}, + "branch": {"type": "number", "minimum": 0, "maximum": 100}, + "toggle": {"type": "number", "minimum": 0, "maximum": 100}, + "user": {"type": "number", "minimum": 0, "maximum": 100} + } + }, + "coverpoints": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["name", "minimum_hits"], + "properties": { + "name": {"type": "string", "pattern": "^[A-Za-z_][A-Za-z0-9_$]*$"}, + "minimum_hits": {"type": "integer", "minimum": 1} + } + } + }, + "exclusions": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["source", "metric", "reason", "owner", "scope"], + "properties": { + "source": {"type": "string", "minLength": 1}, + "metric": {"enum": ["line", "branch", "toggle", "user"]}, + "reason": {"type": "string", "minLength": 1}, + "owner": {"type": "string", "minLength": 1}, + "scope": { + "type": "object", + "additionalProperties": false, + "properties": { + "line_start": {"type": "integer", "minimum": 1}, + "line_end": {"type": "integer", "minimum": 1}, + "name": {"type": "string", "minLength": 1} + }, + "minProperties": 1 + } + } + } + }, + "formal": { + "type": "object", + "additionalProperties": false, + "required": ["required"], + "properties": {"required": {"type": "boolean"}} + } + } +} diff --git a/schemas/openroad-evidence-policy-v1.schema.json b/schemas/openroad-evidence-policy-v1.schema.json new file mode 100644 index 0000000..45c9858 --- /dev/null +++ b/schemas/openroad-evidence-policy-v1.schema.json @@ -0,0 +1,67 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ecaslab.github.io/mosaic-flow/schemas/openroad-evidence-policy-v1.schema.json", + "title": "MOSAIC OpenROAD physical evidence policy", + "type": "object", + "additionalProperties": false, + "required": ["schema", "artifacts", "metrics"], + "properties": { + "schema": {"const": "mosaic-openroad-evidence-policy-v1"}, + "artifacts": { + "type": "array", + "minItems": 1, + "items": {"$ref": "#/$defs/artifact"} + }, + "metrics": { + "type": "array", + "minItems": 1, + "items": {"$ref": "#/$defs/metric"} + } + }, + "$defs": { + "name": { + "type": "string", + "pattern": "^[a-z][a-z0-9_]*$" + }, + "directory": { + "enum": ["results", "reports", "logs", "objects"] + }, + "relativePath": { + "type": "string", + "minLength": 1, + "pattern": "^(?!/)(?!.*(^|/)\\.\\.(/|$)).+$" + }, + "artifact": { + "type": "object", + "additionalProperties": false, + "required": ["name", "directory", "path"], + "properties": { + "name": {"$ref": "#/$defs/name"}, + "directory": {"$ref": "#/$defs/directory"}, + "path": {"$ref": "#/$defs/relativePath"} + } + }, + "metric": { + "type": "object", + "additionalProperties": false, + "required": ["name", "directory", "path", "pattern"], + "anyOf": [ + {"required": ["minimum"]}, + {"required": ["maximum"]} + ], + "properties": { + "name": {"$ref": "#/$defs/name"}, + "directory": {"$ref": "#/$defs/directory"}, + "path": {"$ref": "#/$defs/relativePath"}, + "pattern": { + "type": "string", + "minLength": 1, + "description": "Python regular expression containing one named integer group called value." + }, + "match": {"enum": ["only", "first", "last"]}, + "minimum": {"type": "integer", "minimum": 0}, + "maximum": {"type": "integer", "minimum": 0} + } + } + } +} diff --git a/schemas/qualification-campaigns-v1.schema.json b/schemas/qualification-campaigns-v1.schema.json new file mode 100644 index 0000000..2bd75c8 --- /dev/null +++ b/schemas/qualification-campaigns-v1.schema.json @@ -0,0 +1,151 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://mosaic.dev/schemas/qualification-campaigns-v1.schema.json", + "title": "MOSAIC negative-test and four-state qualification campaigns", + "type": "object", + "additionalProperties": false, + "required": ["schema", "campaigns"], + "properties": { + "schema": {"const": "mosaic-qualification-campaigns-v1"}, + "campaigns": { + "type": "object", + "additionalProperties": false, + "properties": { + "negative": {"$ref": "#/$defs/negativeCampaign"}, + "four_state": {"$ref": "#/$defs/fourStateCampaign"} + }, + "minProperties": 1 + } + }, + "$defs": { + "name": {"type": "string", "pattern": "^[a-z][a-z0-9_]*$"}, + "diagnostics": { + "type": "array", + "items": {"type": "string", "minLength": 1} + }, + "inputs": { + "type": "array", + "items": {"type": "string", "minLength": 1}, + "uniqueItems": true + }, + "phase": { + "type": "object", + "additionalProperties": false, + "required": ["name", "expected"], + "properties": { + "name": {"enum": ["compile", "run"]}, + "command": { + "type": "array", + "items": {"type": "string", "minLength": 1}, + "minItems": 1 + }, + "fixture": {"type": "string", "minLength": 1}, + "expected": {"enum": ["success", "failure"]}, + "diagnostic": {"type": "string", "minLength": 1}, + "failure_class": { + "enum": ["assertion", "elaboration", "equivalence", "mutation", "unknown_control"] + }, + "environment": { + "type": "object", + "additionalProperties": {"type": "string"} + }, + "timeout_seconds": {"type": "integer", "minimum": 1} + }, + "oneOf": [ + {"required": ["command"], "not": {"required": ["fixture"]}}, + {"required": ["fixture"], "not": {"required": ["command"]}} + ] + }, + "caseBase": { + "type": "object", + "required": ["id", "evidence", "role", "kind", "phases"], + "properties": { + "id": {"$ref": "#/$defs/name"}, + "evidence": {"$ref": "#/$defs/name"}, + "role": {"type": "string"}, + "kind": {"type": "string", "minLength": 1}, + "phases": { + "type": "array", + "items": {"$ref": "#/$defs/phase"}, + "minItems": 1, + "maxItems": 2 + }, + "positive_control": {"$ref": "#/$defs/name"}, + "monitor_control": {"$ref": "#/$defs/name"}, + "injections": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["control", "values"], + "properties": { + "control": {"type": "string", "minLength": 1}, + "values": { + "type": "array", + "items": {"enum": ["X", "Z"]}, + "minItems": 1, + "uniqueItems": true + } + } + }, + "minItems": 1 + } + } + }, + "negativeCase": { + "allOf": [ + {"$ref": "#/$defs/caseBase"}, + { + "additionalProperties": false, + "properties": { + "id": {}, "evidence": {}, "kind": {}, "phases": {}, + "role": {"enum": ["negative", "positive_control"]}, + "positive_control": {} + } + } + ] + }, + "fourStateCase": { + "allOf": [ + {"$ref": "#/$defs/caseBase"}, + { + "additionalProperties": false, + "properties": { + "id": {}, "evidence": {}, "kind": {}, "phases": {}, + "role": {"enum": ["unknown_detection", "disabled_monitor_control"]}, + "monitor_control": {}, "injections": {} + } + } + ] + }, + "negativeCampaign": { + "type": "object", + "additionalProperties": false, + "required": ["cases"], + "properties": { + "inputs": {"$ref": "#/$defs/inputs"}, + "infrastructure_diagnostics": {"$ref": "#/$defs/diagnostics"}, + "cases": { + "type": "array", + "items": {"$ref": "#/$defs/negativeCase"}, + "minItems": 1 + } + } + }, + "fourStateCampaign": { + "type": "object", + "additionalProperties": false, + "required": ["simulator", "cases"], + "properties": { + "simulator": {"const": "iverilog"}, + "inputs": {"$ref": "#/$defs/inputs"}, + "infrastructure_diagnostics": {"$ref": "#/$defs/diagnostics"}, + "cases": { + "type": "array", + "items": {"$ref": "#/$defs/fourStateCase"}, + "minItems": 1 + } + } + } + } +} diff --git a/schemas/release-evidence-v1.schema.json b/schemas/release-evidence-v1.schema.json new file mode 100644 index 0000000..9c08583 --- /dev/null +++ b/schemas/release-evidence-v1.schema.json @@ -0,0 +1,409 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ecaslab.github.io/mosaic-flow/schemas/release-evidence-v1.schema.json", + "title": "MOSAIC release evidence manifest", + "type": "object", + "additionalProperties": false, + "required": ["schema", "deterministic", "volatile"], + "properties": { + "schema": { + "const": "mosaic-release-evidence-v1" + }, + "deterministic": { + "$ref": "#/$defs/deterministic" + }, + "volatile": { + "$ref": "#/$defs/volatile" + } + }, + "$defs": { + "name": { + "type": "string", + "pattern": "^[a-z][a-z0-9_]*$" + }, + "revision": { + "type": "string", + "pattern": "^[0-9a-f]{40}$" + }, + "relativePath": { + "type": "string", + "minLength": 1, + "pattern": "^(?!/)(?!.*(^|/)\\.\\.(/|$)).+$" + }, + "sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "hashRecord": { + "type": "object", + "additionalProperties": false, + "required": ["path", "sha256"], + "properties": { + "path": { + "$ref": "#/$defs/relativePath" + }, + "sha256": { + "$ref": "#/$defs/sha256" + } + } + }, + "identity": { + "type": "object", + "additionalProperties": false, + "required": [ + "module", + "profile", + "module_revision", + "methodology_revision", + "methodology_version", + "execution_context" + ], + "properties": { + "module": { + "$ref": "#/$defs/name" + }, + "profile": { + "$ref": "#/$defs/name" + }, + "module_revision": { + "$ref": "#/$defs/revision" + }, + "methodology_revision": { + "$ref": "#/$defs/revision" + }, + "methodology_version": { + "type": "string", + "minLength": 1 + }, + "execution_context": { + "$ref": "#/$defs/name" + } + } + }, + "technology": { + "type": "object", + "additionalProperties": false, + "required": ["name", "details"], + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "details": { + "type": "object" + } + } + }, + "flow": { + "type": "object", + "additionalProperties": false, + "required": ["id", "policy", "status", "status_evidence"], + "properties": { + "id": { + "$ref": "#/$defs/name" + }, + "policy": { + "enum": ["required", "approved_skip"] + }, + "status": { + "enum": ["PASS", "SKIP"] + }, + "status_evidence": { + "oneOf": [ + { + "$ref": "#/$defs/hashRecord" + }, + { + "type": "null" + } + ] + } + }, + "allOf": [ + { + "if": { + "properties": { + "status": { + "const": "PASS" + } + } + }, + "then": { + "properties": { + "policy": { + "const": "required" + }, + "status_evidence": { + "$ref": "#/$defs/hashRecord" + } + } + } + }, + { + "if": { + "properties": { + "status": { + "const": "SKIP" + } + } + }, + "then": { + "properties": { + "policy": { + "const": "approved_skip" + } + } + } + } + ] + }, + "supplementalGate": { + "type": "object", + "additionalProperties": false, + "required": ["id", "status", "status_evidence"], + "properties": { + "id": { + "$ref": "#/$defs/name" + }, + "status": { + "const": "PASS" + }, + "status_evidence": { + "$ref": "#/$defs/hashRecord" + } + } + }, + "tool": { + "type": "object", + "additionalProperties": false, + "required": ["name", "context", "command", "flows", "version"], + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "context": { + "$ref": "#/$defs/name" + }, + "command": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "flows": { + "type": "array", + "items": { + "$ref": "#/$defs/name" + }, + "uniqueItems": true + }, + "source": { + "$ref": "#/$defs/hashRecord" + }, + "version": { + "type": "string", + "minLength": 1 + } + } + }, + "input": { + "type": "object", + "additionalProperties": false, + "required": ["path", "roles", "sha256"], + "properties": { + "path": { + "$ref": "#/$defs/relativePath" + }, + "roles": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "enum": ["declared", "filelist", "include", "source"] + } + }, + "sha256": { + "$ref": "#/$defs/sha256" + } + } + }, + "coverage": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "producer", "path", "sha256"], + "properties": { + "kind": { + "type": "string", + "minLength": 1 + }, + "producer": { + "type": "string", + "minLength": 1 + }, + "path": { + "$ref": "#/$defs/relativePath" + }, + "sha256": { + "$ref": "#/$defs/sha256" + } + } + }, + "deterministic": { + "type": "object", + "additionalProperties": false, + "required": [ + "identity", + "parameters", + "technology", + "flows", + "supplemental_gates", + "tools", + "inputs", + "evidence", + "metadata" + ], + "properties": { + "identity": { + "$ref": "#/$defs/identity" + }, + "parameters": { + "type": "object" + }, + "technology": { + "$ref": "#/$defs/technology" + }, + "flows": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/flow" + } + }, + "supplemental_gates": { + "type": "array", + "items": { + "$ref": "#/$defs/supplementalGate" + } + }, + "tools": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/tool" + } + }, + "inputs": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/input" + } + }, + "evidence": { + "type": "object", + "additionalProperties": false, + "required": ["additional", "coverage"], + "properties": { + "additional": { + "type": "array", + "items": { + "$ref": "#/$defs/hashRecord" + } + }, + "coverage": { + "type": "array", + "items": { + "$ref": "#/$defs/coverage" + } + } + } + }, + "metadata": { + "type": "object" + } + } + }, + "toolPath": { + "type": "object", + "additionalProperties": false, + "required": ["name", "context", "path"], + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "context": { + "$ref": "#/$defs/name" + }, + "path": { + "type": "string", + "minLength": 1 + } + } + }, + "volatile": { + "type": "object", + "additionalProperties": false, + "required": ["generated_at", "source_tree", "execution"], + "properties": { + "generated_at": { + "type": "string", + "format": "date-time" + }, + "source_tree": { + "type": "object", + "additionalProperties": false, + "required": ["module_dirty", "methodology_dirty"], + "properties": { + "module_dirty": { + "type": "boolean" + }, + "methodology_dirty": { + "type": "boolean" + } + } + }, + "execution": { + "type": "object", + "additionalProperties": false, + "required": ["context", "host", "tool_paths", "metadata"], + "properties": { + "context": { + "$ref": "#/$defs/name" + }, + "host": { + "type": "object", + "additionalProperties": false, + "required": ["machine", "platform", "python_executable"], + "properties": { + "machine": { + "type": "string", + "minLength": 1 + }, + "platform": { + "type": "string", + "minLength": 1 + }, + "python_executable": { + "type": "string", + "minLength": 1 + } + } + }, + "tool_paths": { + "type": "array", + "items": { + "$ref": "#/$defs/toolPath" + } + }, + "metadata": { + "type": "object" + } + } + } + } + } + } +} diff --git a/schemas/static-intent-v1.schema.json b/schemas/static-intent-v1.schema.json new file mode 100644 index 0000000..d8dc3d1 --- /dev/null +++ b/schemas/static-intent-v1.schema.json @@ -0,0 +1,140 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ecaslab.github.io/mosaic-flow/schemas/static-intent-v1.schema.json", + "title": "MOSAIC portable static-intent expectations", + "type": "object", + "additionalProperties": false, + "required": ["schema"], + "anyOf": [ + {"required": ["sdc"]}, + {"required": ["upf"]} + ], + "properties": { + "schema": {"const": "mosaic-static-intent-v1"}, + "sdc": {"$ref": "#/$defs/sdc"}, + "upf": {"$ref": "#/$defs/upf"} + }, + "$defs": { + "name": { + "type": "string", + "pattern": "^[A-Za-z_][A-Za-z0-9_.-]*$" + }, + "relativePath": { + "type": "string", + "minLength": 1, + "pattern": "^(?!/)(?!.*(^|/)\\.\\.(/|$)).+$" + }, + "stringArray": { + "type": "array", + "uniqueItems": true, + "items": {"type": "string", "minLength": 1} + }, + "expectedPorts": { + "type": "object", + "additionalProperties": false, + "properties": { + "clock": {"$ref": "#/$defs/stringArray"}, + "input_delay": {"$ref": "#/$defs/stringArray"}, + "output_delay": {"$ref": "#/$defs/stringArray"} + } + }, + "clock": { + "type": "object", + "additionalProperties": false, + "required": ["name", "period", "port"], + "properties": { + "name": {"$ref": "#/$defs/name"}, + "period": {"type": "number", "exclusiveMinimum": 0}, + "port": {"$ref": "#/$defs/name"} + } + }, + "generatedClock": { + "type": "object", + "additionalProperties": false, + "required": ["name", "source_port", "output_port", "combinational"], + "properties": { + "name": {"$ref": "#/$defs/name"}, + "source_port": {"$ref": "#/$defs/name"}, + "output_port": {"$ref": "#/$defs/name"}, + "combinational": {"type": "boolean"} + } + }, + "exception": { + "type": "object", + "additionalProperties": false, + "required": ["command", "from_ports", "to_ports"], + "properties": { + "command": {"const": "set_false_path"}, + "from_ports": {"$ref": "#/$defs/stringArray"}, + "to_ports": {"$ref": "#/$defs/stringArray"} + } + }, + "sdcProfile": { + "type": "object", + "additionalProperties": false, + "required": ["name", "path", "kind"], + "properties": { + "name": {"$ref": "#/$defs/name"}, + "path": {"$ref": "#/$defs/relativePath"}, + "kind": {"enum": ["sequential", "combinational", "clock_gating", "reset_synchronizer"]}, + "expected_ports": {"$ref": "#/$defs/expectedPorts"}, + "clocks": {"type": "array", "items": {"$ref": "#/$defs/clock"}}, + "generated_clocks": {"type": "array", "items": {"$ref": "#/$defs/generatedClock"}}, + "intentional_exceptions": {"type": "array", "items": {"$ref": "#/$defs/exception"}}, + "forbidden_commands": {"$ref": "#/$defs/stringArray"} + } + }, + "consistency": { + "type": "object", + "additionalProperties": false, + "required": ["profiles", "commands"], + "properties": { + "profiles": {"type": "array", "minItems": 2, "uniqueItems": true, "items": {"$ref": "#/$defs/name"}}, + "commands": {"$ref": "#/$defs/stringArray"} + } + }, + "sdc": { + "type": "object", + "additionalProperties": false, + "required": ["profiles"], + "properties": { + "profiles": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/sdcProfile"}}, + "consistency": {"type": "array", "items": {"$ref": "#/$defs/consistency"}} + } + }, + "upfRequired": { + "type": "object", + "additionalProperties": false, + "properties": { + "power_domains": {"$ref": "#/$defs/stringArray"}, + "supply_ports": {"$ref": "#/$defs/stringArray"}, + "supply_nets": {"$ref": "#/$defs/stringArray"}, + "supply_connections": {"$ref": "#/$defs/stringArray"}, + "domain_supplies": {"$ref": "#/$defs/stringArray"}, + "port_states": {"$ref": "#/$defs/stringArray"}, + "power_state_tables": {"$ref": "#/$defs/stringArray"}, + "power_states": {"$ref": "#/$defs/stringArray"}, + "port_attribute_ports": {"$ref": "#/$defs/stringArray"}, + "isolation": {"$ref": "#/$defs/stringArray"}, + "level_shifting": {"$ref": "#/$defs/stringArray"}, + "retention": {"$ref": "#/$defs/stringArray"} + } + }, + "upf": { + "type": "object", + "additionalProperties": false, + "required": ["path", "top", "mode", "required", "forbidden_strategies"], + "properties": { + "path": {"$ref": "#/$defs/relativePath"}, + "top": {"$ref": "#/$defs/name"}, + "mode": {"enum": ["always_on", "boundary"]}, + "required": {"$ref": "#/$defs/upfRequired"}, + "forbidden_strategies": { + "type": "array", + "uniqueItems": true, + "items": {"enum": ["isolation", "level_shifting", "retention", "power_switch"]} + } + } + } + } +} diff --git a/tests/check_fixture_parameter_profile_evidence.sh b/tests/check_fixture_parameter_profile_evidence.sh new file mode 100755 index 0000000..c5df70d --- /dev/null +++ b/tests/check_fixture_parameter_profile_evidence.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +set -euo pipefail + +flow_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +report_root="${flow_root}/tests/fixture-parameter-profiles/reports" +work_root="${flow_root}/tests/fixture-parameter-profiles/work" + +python3 - "${report_root}" "${work_root}" <<'PY' +import json +import pathlib +import sys + +report_root = pathlib.Path(sys.argv[1]) +work_root = pathlib.Path(sys.argv[2]) +summary = json.loads( + (report_root / "parameter-profile-summary.json").read_text(encoding="utf-8") +) + +expected_parameters = { + "width_min": {"FEATURE_INVERT": False, "WIDTH": 1}, + "nominal": {"FEATURE_INVERT": False, "WIDTH": 8}, + "feature_invert": {"FEATURE_INVERT": True, "WIDTH": 5}, + "elaboration_only": {"FEATURE_INVERT": False, "WIDTH": 3}, +} +portable_flows = { + "verible_lint", + "verible_format", + "slang_elaboration", + "verilator_lint", + "yosys_synthesis", + "symbiyosys_formal", + "eqy_equivalence", + "verilator_sim", + "pyuvm_open_source", +} +reported_flows = portable_flows | { + "coverage_qualification", + "negative_qualification", + "four_state_qualification", + "static_intent", +} +elaboration_flows = { + "verible_lint", + "verible_format", + "slang_elaboration", + "verilator_lint", +} + +assert summary["aggregate_status"] == "PASS" +assert [entry["profile"] for entry in summary["profiles"]] == list( + expected_parameters +) + +for entry in summary["profiles"]: + profile = entry["profile"] + assert entry["parameters"] == expected_parameters[profile] + expected_pass = elaboration_flows if profile == "elaboration_only" else portable_flows + assert set(entry["statuses"]) == reported_flows + for flow, status in entry["statuses"].items(): + assert status == ("PASS" if flow in expected_pass else "SKIP"), ( + profile, + flow, + status, + ) + + evidence = json.loads( + (report_root / profile / "parameter-profile.json").read_text( + encoding="utf-8" + ) + ) + assert evidence["parameters"] == expected_parameters[profile] + +for profile in ("width_min", "nominal", "feature_invert"): + parameters = expected_parameters[profile] + expected_commands = { + f"chparam -set FEATURE_INVERT {int(parameters['FEATURE_INVERT'])} profile_fixture_formal", + f"chparam -set WIDTH {parameters['WIDTH']} profile_fixture_formal", + } + formal_config = ( + report_root / profile / "symbiyosys_formal" / "profile.sby" + ).read_text(encoding="utf-8") + assert expected_commands.issubset(set(formal_config.splitlines())) + + eqy_config = (report_root / profile / "eqy_equivalence" / "profile.eqy").read_text( + encoding="utf-8" + ) + expected_netlist = ( + work_root / profile / "yosys_synthesis" / "profile_fixture_netlist.v" + ) + assert str(expected_netlist) in eqy_config + assert expected_netlist.is_file() + assert f"chparam -set WIDTH {parameters['WIDTH']} profile_fixture" in eqy_config + assert ( + f"chparam -set FEATURE_INVERT {int(parameters['FEATURE_INVERT'])} " + "profile_fixture" + ) in eqy_config + +print("Parameter-profile fixture evidence is complete and isolated") +PY diff --git a/tests/check_fixture_release_manifest.sh b/tests/check_fixture_release_manifest.sh new file mode 100755 index 0000000..a27fe07 --- /dev/null +++ b/tests/check_fixture_release_manifest.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash +set -euo pipefail + +flow_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +manifest_path="${flow_root}/tests/fixture-module/reports/release_manifest/native/manifest.json" +summary_path="${flow_root}/tests/fixture-module/reports/release_manifest/native/summary.txt" +status_path="${flow_root}/tests/fixture-module/reports/release_manifest/native/status.txt" + +test "$(<"${status_path}")" = PASS +grep -Fq "MOSAIC release evidence" "${summary_path}" +grep -Fq "Execution context: native" "${summary_path}" + +python3 - "${manifest_path}" "${EXPECT_CLEAN_RELEASE:-false}" <<'PY' +import json +import pathlib +import re +import sys + +manifest = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8")) +expect_clean = sys.argv[2] == "true" +deterministic = manifest["deterministic"] +identity = deterministic["identity"] + +assert manifest["schema"] == "mosaic-release-evidence-v1" +assert identity["module"] == "flow_fixture" +assert identity["profile"] == "default" +assert identity["execution_context"] == "native" +assert re.fullmatch(r"[0-9a-f]{40}", identity["module_revision"]) +assert re.fullmatch(r"[0-9a-f]{40}", identity["methodology_revision"]) + +expected_pass = { + "eqy_equivalence", + "pyuvm_open_source", + "coverage_qualification", + "negative_qualification", + "four_state_qualification", + "static_intent", + "slang_elaboration", + "symbiyosys_formal", + "verible_format", + "verible_lint", + "verilator_lint", + "verilator_sim", + "yosys_synthesis", +} +flow_statuses = {entry["id"]: entry["status"] for entry in deterministic["flows"]} +assert set(flow_statuses) == { + "eqy_equivalence", + "openroad", + "pyuvm_commercial", + "pyuvm_open_source", + "coverage_qualification", + "negative_qualification", + "four_state_qualification", + "static_intent", + "sg_cdc", + "sg_dft", + "slang_elaboration", + "symbiyosys_formal", + "synopsys_primepower", + "synopsys_primetime", + "synopsys_synthesis", + "vc_cdc", + "vc_lint", + "vc_lp", + "vcs_sim", + "verible_format", + "verible_lint", + "verilator_lint", + "verilator_sim", + "yosys_synthesis", +} +assert { + name for name, status in flow_statuses.items() if status == "PASS" +} == expected_pass +assert all( + status == "SKIP" + for name, status in flow_statuses.items() + if name not in expected_pass +) + +expected_tools = { + "cocotb", + "eqy", + "iverilog", + "python", + "pyuvm", + "slang", + "symbiyosys", + "verible-verilog-format", + "verible-verilog-lint", + "verilator", + "yosys", + "vvp", +} +tools = deterministic["tools"] +assert {entry["name"] for entry in tools} == expected_tools +assert all(entry["version"] for entry in tools) +assert all(entry["source"]["sha256"] for entry in tools if "source" in entry) + +input_paths = {entry["path"] for entry in deterministic["inputs"]} +assert { + "config/design.mk", + "config/coverage-policy.json", + "config/qualification-campaigns.json", + "config/qualification-equivalent.eqy", + "config/qualification-inequivalent.eqy", + "config/static-intent.json", + "config/flows.mk", + "config/formal.sby", + "config/formal_cover.sby", + "config/equivalence.eqy", + "config/openroad.mk", + "config/verible.rules", + "config/verible_waivers.txt", + "config/verilator_waivers.vlt", + "constraints/timing.sdc", + "constraints/power.upf", + "filelists/assertions.f", + "filelists/coverage.f", + "filelists/properties.f", + "filelists/rtl.f", + "filelists/tb.f", + "rtl/flow_fixture.sv", + "verif/mutations/flow_fixture_bad_netlist.v", + "verif/pyuvm/test_flow_fixture.py", + "verif/tb/flow_fixture_four_state_tb.sv", +} <= input_paths +assert all( + re.fullmatch(r"[0-9a-f]{64}", entry["sha256"]) + for entry in deterministic["inputs"] +) +assert not any( + "__pycache__" in path or path.endswith(".pyc") for path in input_paths +) + +coverage = deterministic["evidence"]["coverage"] +assert {(entry["producer"], entry["kind"]) for entry in coverage} >= { + ("coverage_qualification", "coverage_qualification_summary"), + ("pyuvm_open_source", "pyuvm_functional"), + ("pyuvm_open_source", "systemverilog_native_report"), + ("verilator_sim", "systemverilog_native_report"), +} +additional_paths = {entry["path"] for entry in deterministic["evidence"]["additional"]} +assert { + "reports/negative_qualification/summary.json", + "reports/four_state_qualification/summary.json", + "reports/static_intent/sdc-findings.json", + "reports/static_intent/upf-findings.json", + "reports/static_intent/summary.json", +} <= additional_paths +if expect_clean: + assert manifest["volatile"]["source_tree"] == { + "methodology_dirty": False, + "module_dirty": False, + } +PY + +echo "Complete fixture release manifest is valid" diff --git a/tests/fixture-module/config/coverage-policy.json b/tests/fixture-module/config/coverage-policy.json new file mode 100644 index 0000000..5253fd9 --- /dev/null +++ b/tests/fixture-module/config/coverage-policy.json @@ -0,0 +1,25 @@ +{ + "schema": "mosaic-coverage-policy-v1", + "scope": { + "include": [ + "rtl/*.sv", + "verif/coverage/*.sv" + ] + }, + "thresholds": { + "line": 0, + "branch": 0, + "toggle": 0, + "user": 100 + }, + "coverpoints": [ + {"name": "enabled_operation", "minimum_hits": 1}, + {"name": "disabled_operation", "minimum_hits": 1}, + {"name": "representative_data", "minimum_hits": 1}, + {"name": "representative_output", "minimum_hits": 1} + ], + "exclusions": [], + "formal": { + "required": true + } +} diff --git a/tests/fixture-module/config/design.mk b/tests/fixture-module/config/design.mk index d32b275..18a6266 100644 --- a/tests/fixture-module/config/design.mk +++ b/tests/fixture-module/config/design.mk @@ -25,8 +25,17 @@ export VERIBLE_RULES_FILE := $(CURDIR)/config/verible.rules # Proof and cover reachability use separate SymbiYosys tasks. export FORMAL_CONFIG := $(CURDIR)/config/formal.sby export FORMAL_COVER_CONFIG := $(CURDIR)/config/formal_cover.sby +export COVERAGE_QUALIFICATION_POLICY := $(CURDIR)/config/coverage-policy.json +export COVERAGE_QUALIFICATION_SOURCE := verilator_sim +export QUALIFICATION_CAMPAIGN_MANIFEST := $(CURDIR)/config/qualification-campaigns.json +export STATIC_INTENT_CONFIG := $(CURDIR)/config/static-intent.json export EQUIVALENCE_CONFIG := $(CURDIR)/config/equivalence.eqy export OPENROAD_CONFIG := $(CURDIR)/config/openroad.mk +export OPENROAD_CONSTRAINT_FILE := $(CURDIR)/constraints/openroad.sdc +export OPENROAD_EVIDENCE_POLICY := $(CURDIR)/config/openroad-evidence.json +export OPENROAD_PLATFORM := nangate45 +export OPENROAD_FLOW_VARIANT := fixture_release +export OPENROAD_DESIGN_NAME := $(DESIGN_TOP) export CONSTRAINT_DIR := $(CURDIR)/constraints export REPORT_DIR := $(CURDIR)/reports export WORK_DIR := $(CURDIR)/work diff --git a/tests/fixture-module/config/flows.mk b/tests/fixture-module/config/flows.mk index c3b0e8d..02e8672 100644 --- a/tests/fixture-module/config/flows.mk +++ b/tests/fixture-module/config/flows.mk @@ -7,6 +7,10 @@ FLOW_symbiyosys_formal := enabled FLOW_eqy_equivalence := enabled FLOW_verilator_sim := enabled FLOW_pyuvm_open_source := enabled +FLOW_coverage_qualification := enabled +FLOW_negative_qualification := enabled +FLOW_four_state_qualification := enabled +FLOW_static_intent := enabled FLOW_openroad := enabled FLOW_vcs_sim := enabled FLOW_pyuvm_commercial := disabled @@ -20,5 +24,6 @@ FLOW_synopsys_primetime := enabled FLOW_synopsys_primepower := enabled FLOW_DEPENDENCIES_eqy_equivalence := yosys_synthesis +FLOW_DEPENDENCIES_coverage_qualification := verilator_sim FLOW_DEPENDENCIES_synopsys_primetime := synopsys_synthesis FLOW_DEPENDENCIES_synopsys_primepower := vcs_sim synopsys_synthesis diff --git a/tests/fixture-module/config/formal_cover_unreachable.sby b/tests/fixture-module/config/formal_cover_unreachable.sby new file mode 100644 index 0000000..dd0fd27 --- /dev/null +++ b/tests/fixture-module/config/formal_cover_unreachable.sby @@ -0,0 +1,13 @@ +[options] +mode cover +depth 4 + +[engines] +smtbmc bitwuzla + +[script] +read -formal -sv flow_fixture_unreachable_cover.sv +prep -top flow_fixture_unreachable_cover + +[files] +verif/formal/flow_fixture_unreachable_cover.sv diff --git a/tests/fixture-module/config/openroad-evidence.json b/tests/fixture-module/config/openroad-evidence.json new file mode 100644 index 0000000..49b8b21 --- /dev/null +++ b/tests/fixture-module/config/openroad-evidence.json @@ -0,0 +1,55 @@ +{ + "schema": "mosaic-openroad-evidence-policy-v1", + "artifacts": [ + {"name": "final_def", "directory": "results", "path": "6_final.def"}, + {"name": "final_gds", "directory": "results", "path": "6_final.gds"}, + {"name": "final_odb", "directory": "results", "path": "6_final.odb"}, + {"name": "final_sdc", "directory": "results", "path": "6_final.sdc"}, + {"name": "final_netlist", "directory": "results", "path": "6_final.v"} + ], + "metrics": [ + { + "name": "setup_violations", + "directory": "reports", + "path": "6_finish.rpt", + "pattern": "^setup violation count[ \\t]+(?P[0-9]+)$", + "maximum": 0 + }, + { + "name": "hold_violations", + "directory": "reports", + "path": "6_finish.rpt", + "pattern": "^hold violation count[ \\t]+(?P[0-9]+)$", + "maximum": 0 + }, + { + "name": "slew_violations", + "directory": "reports", + "path": "6_finish.rpt", + "pattern": "^max slew violation count[ \\t]+(?P[0-9]+)$", + "maximum": 0 + }, + { + "name": "fanout_violations", + "directory": "reports", + "path": "6_finish.rpt", + "pattern": "^max fanout violation count[ \\t]+(?P[0-9]+)$", + "maximum": 0 + }, + { + "name": "capacitance_violations", + "directory": "reports", + "path": "6_finish.rpt", + "pattern": "^max cap violation count[ \\t]+(?P[0-9]+)$", + "maximum": 0 + }, + { + "name": "routing_violations", + "directory": "logs", + "path": "5_2_route.log", + "pattern": "Number of violations = (?P[0-9]+)", + "match": "last", + "maximum": 0 + } + ] +} diff --git a/tests/fixture-module/config/openroad.mk b/tests/fixture-module/config/openroad.mk index 88f045a..a617f42 100644 --- a/tests/fixture-module/config/openroad.mk +++ b/tests/fixture-module/config/openroad.mk @@ -1,7 +1,7 @@ export DESIGN_NAME = flow_fixture export PLATFORM = $(OPENROAD_PLATFORM) export VERILOG_FILES = $(REPO_ROOT)/rtl/flow_fixture.sv -export SDC_FILE = $(REPO_ROOT)/constraints/timing.sdc -export CORE_UTILIZATION = 35 -export CORE_ASPECT_RATIO = 1 -export CORE_MARGIN = 2 +export SDC_FILE = $(REPO_ROOT)/constraints/openroad.sdc +export DIE_AREA = 0 0 60 60 +export CORE_AREA = 5 5 55 55 +export SKIP_CTS_REPAIR_TIMING = 1 diff --git a/tests/fixture-module/config/qualification-campaigns.json b/tests/fixture-module/config/qualification-campaigns.json new file mode 100644 index 0000000..3ffe354 --- /dev/null +++ b/tests/fixture-module/config/qualification-campaigns.json @@ -0,0 +1,192 @@ +{ + "schema": "mosaic-qualification-campaigns-v1", + "campaigns": { + "negative": { + "inputs": [ + "config/qualification-equivalent.eqy", + "config/qualification-inequivalent.eqy", + "verif/mutations/flow_fixture_bad_netlist.v" + ], + "cases": [ + { + "id": "verification_positive_control", + "evidence": "verification_positive_control", + "role": "positive_control", + "kind": "positive_control", + "phases": [ + { + "name": "run", + "command": [ + "{python}", + "{flow_root}/tests/fixtures/qualification_driver.py", + "--diagnostic", + "POSITIVE_CONTROL_PASS", + "--exit-code", + "0" + ], + "expected": "success", + "diagnostic": "POSITIVE_CONTROL_PASS" + } + ] + }, + { + "id": "equivalence_positive_control", + "evidence": "equivalence_positive_control", + "role": "positive_control", + "kind": "equivalence_control", + "phases": [ + { + "name": "run", + "command": [ + "{eqy}", + "-f", + "-d", + "{case_work_dir}/eqy", + "config/qualification-equivalent.eqy" + ], + "expected": "success", + "diagnostic": "Successfully proved designs equivalent" + } + ] + }, + { + "id": "detected_simulation_mutation", + "evidence": "detected_simulation_mutation", + "role": "negative", + "kind": "simulation_mutation", + "positive_control": "verification_positive_control", + "phases": [ + { + "name": "run", + "command": [ + "{python}", + "{flow_root}/tests/fixtures/qualification_driver.py", + "--diagnostic", + "ASSERTION_FAILURE_DETECTED", + "--exit-code", + "1" + ], + "expected": "failure", + "diagnostic": "ASSERTION_FAILURE_DETECTED", + "failure_class": "assertion" + } + ] + }, + { + "id": "invalid_parameter_elaboration", + "evidence": "invalid_parameter_elaboration", + "role": "negative", + "kind": "invalid_parameter", + "positive_control": "equivalence_positive_control", + "phases": [ + { + "name": "compile", + "command": [ + "{python}", + "{flow_root}/tests/fixtures/qualification_driver.py", + "--diagnostic", + "INVALID_PARAMETER_DETECTED", + "--exit-code", + "1" + ], + "expected": "failure", + "diagnostic": "INVALID_PARAMETER_DETECTED", + "failure_class": "elaboration" + } + ] + }, + { + "id": "expected_equivalence_mismatch", + "evidence": "expected_equivalence_mismatch", + "role": "negative", + "kind": "equivalence_mismatch", + "positive_control": "verification_positive_control", + "phases": [ + { + "name": "run", + "command": [ + "{eqy}", + "-f", + "-d", + "{case_work_dir}/eqy", + "config/qualification-inequivalent.eqy" + ], + "expected": "failure", + "diagnostic": "Successfully proved designs inequivalent|Failed to prove equivalence", + "failure_class": "equivalence" + } + ] + } + ] + }, + "four_state": { + "simulator": "iverilog", + "inputs": ["verif/tb/flow_fixture_four_state_tb.sv"], + "cases": [ + { + "id": "disabled_monitor_control", + "evidence": "disabled_monitor_control", + "role": "disabled_monitor_control", + "kind": "stimulus_control", + "injections": [ + {"control": "control_i", "values": ["X", "Z"]} + ], + "phases": [ + { + "name": "compile", + "command": [ + "{iverilog}", + "-g2012", + "-s", + "flow_fixture_four_state_tb", + "-DFLOW_FIXTURE_DISABLE_UNKNOWN_MONITOR", + "-o", + "{case_work_dir}/simulation.vvp", + "verif/tb/flow_fixture_four_state_tb.sv" + ], + "expected": "success" + }, + { + "name": "run", + "command": ["{vvp}", "{case_work_dir}/simulation.vvp"], + "expected": "success", + "diagnostic": "UNKNOWN_CONTROL_STIMULUS_REACHED" + } + ] + }, + { + "id": "unknown_control_detection", + "evidence": "unknown_control_detection", + "role": "unknown_detection", + "kind": "control_unknown", + "monitor_control": "disabled_monitor_control", + "injections": [ + {"control": "control_i", "values": ["X", "Z"]} + ], + "phases": [ + { + "name": "compile", + "command": [ + "{iverilog}", + "-g2012", + "-s", + "flow_fixture_four_state_tb", + "-o", + "{case_work_dir}/simulation.vvp", + "verif/tb/flow_fixture_four_state_tb.sv" + ], + "expected": "success" + }, + { + "name": "run", + "command": ["{vvp}", "{case_work_dir}/simulation.vvp"], + "expected": "failure", + "diagnostic": "UNKNOWN_CONTROL_XZ_DETECTED", + "failure_class": "unknown_control" + } + ] + } + ] + } + } +} diff --git a/tests/fixture-module/config/qualification-equivalent.eqy b/tests/fixture-module/config/qualification-equivalent.eqy new file mode 100644 index 0000000..1ee93d6 --- /dev/null +++ b/tests/fixture-module/config/qualification-equivalent.eqy @@ -0,0 +1,12 @@ +[gold] +read_verilog -sv rtl/flow_fixture.sv +prep -top flow_fixture + +[gate] +read_verilog -sv rtl/flow_fixture.sv +prep -top flow_fixture + +[strategy sby] +use sby +depth 8 +engine smtbmc bitwuzla diff --git a/tests/fixture-module/config/qualification-inequivalent.eqy b/tests/fixture-module/config/qualification-inequivalent.eqy new file mode 100644 index 0000000..e0fd0d8 --- /dev/null +++ b/tests/fixture-module/config/qualification-inequivalent.eqy @@ -0,0 +1,12 @@ +[gold] +read_verilog -sv rtl/flow_fixture.sv +prep -top flow_fixture + +[gate] +read_verilog -sv verif/mutations/flow_fixture_bad_netlist.v +prep -top flow_fixture + +[strategy sby] +use sby +depth 8 +engine smtbmc bitwuzla diff --git a/tests/fixture-module/config/static-intent.json b/tests/fixture-module/config/static-intent.json new file mode 100644 index 0000000..1077ab8 --- /dev/null +++ b/tests/fixture-module/config/static-intent.json @@ -0,0 +1,60 @@ +{ + "schema": "mosaic-static-intent-v1", + "sdc": { + "profiles": [ + { + "name": "synthesis", + "path": "constraints/timing.sdc", + "kind": "sequential", + "expected_ports": { + "clock": ["clk_i"], + "input_delay": ["rst_ni", "enable_i", "data_i"], + "output_delay": ["data_o"] + }, + "clocks": [{"name": "core_clk", "period": 10.0, "port": "clk_i"}], + "intentional_exceptions": [ + {"command": "set_false_path", "from_ports": ["rst_ni"], "to_ports": []} + ] + }, + { + "name": "physical", + "path": "constraints/timing.sdc", + "kind": "sequential", + "expected_ports": { + "clock": ["clk_i"], + "input_delay": ["rst_ni", "enable_i", "data_i"], + "output_delay": ["data_o"] + }, + "clocks": [{"name": "core_clk", "period": 10.0, "port": "clk_i"}], + "intentional_exceptions": [ + {"command": "set_false_path", "from_ports": ["rst_ni"], "to_ports": []} + ] + } + ], + "consistency": [ + { + "profiles": ["synthesis", "physical"], + "commands": [ + "create_clock", + "set_clock_uncertainty", + "set_input_delay", + "set_output_delay", + "set_false_path" + ] + } + ] + }, + "upf": { + "path": "constraints/power.upf", + "top": "flow_fixture", + "mode": "always_on", + "required": { + "power_domains": ["PD_FIXTURE"], + "supply_ports": ["VDD", "VSS"], + "supply_nets": ["VDD", "VSS"], + "supply_connections": ["VDD", "VSS"], + "domain_supplies": ["PD_FIXTURE"] + }, + "forbidden_strategies": [] + } +} diff --git a/tests/fixture-module/constraints/openroad.sdc b/tests/fixture-module/constraints/openroad.sdc new file mode 100644 index 0000000..9e1101a --- /dev/null +++ b/tests/fixture-module/constraints/openroad.sdc @@ -0,0 +1,5 @@ +create_clock -name core_clk -period 10.000 [get_ports clk_i] +set_clock_uncertainty 0.100 [get_clocks core_clk] +set_input_delay 1.000 -clock core_clk [get_ports {enable_i data_i[*]}] +set_output_delay 1.000 -clock core_clk [get_ports data_o[*]] +set_false_path -from [get_ports rst_ni] diff --git a/tests/fixture-module/constraints/power.upf b/tests/fixture-module/constraints/power.upf new file mode 100644 index 0000000..15b1463 --- /dev/null +++ b/tests/fixture-module/constraints/power.upf @@ -0,0 +1,9 @@ +set_design_top flow_fixture +create_power_domain PD_FIXTURE -include_scope +create_supply_port VDD -domain PD_FIXTURE +create_supply_port VSS -domain PD_FIXTURE +create_supply_net VDD -domain PD_FIXTURE +create_supply_net VSS -domain PD_FIXTURE +connect_supply_net VDD -ports VDD +connect_supply_net VSS -ports VSS +set_domain_supply_net PD_FIXTURE -primary_power_net VDD -primary_ground_net VSS diff --git a/tests/fixture-module/constraints/timing.sdc b/tests/fixture-module/constraints/timing.sdc index 7a5c43d..2d7741c 100644 --- a/tests/fixture-module/constraints/timing.sdc +++ b/tests/fixture-module/constraints/timing.sdc @@ -1,4 +1,5 @@ create_clock -name core_clk -period 10.000 [get_ports clk_i] +set_clock_uncertainty 0.100 [get_clocks core_clk] set_input_delay 1.000 -clock core_clk [remove_from_collection [all_inputs] [get_ports clk_i]] set_output_delay 1.000 -clock core_clk [all_outputs] set_false_path -from [get_ports rst_ni] diff --git a/tests/fixture-module/verif/formal/flow_fixture_unreachable_cover.sv b/tests/fixture-module/verif/formal/flow_fixture_unreachable_cover.sv new file mode 100644 index 0000000..5fae9bd --- /dev/null +++ b/tests/fixture-module/verif/formal/flow_fixture_unreachable_cover.sv @@ -0,0 +1,10 @@ +// Negative formal fixture proving that unreachable cover statements fail the gate. +module flow_fixture_unreachable_cover; + + logic clk_i; + + always_ff @(posedge clk_i) begin + cover (1'b0); + end + +endmodule diff --git a/tests/fixture-module/verif/mutations/flow_fixture_bad_netlist.v b/tests/fixture-module/verif/mutations/flow_fixture_bad_netlist.v new file mode 100644 index 0000000..e8f1efa --- /dev/null +++ b/tests/fixture-module/verif/mutations/flow_fixture_bad_netlist.v @@ -0,0 +1,16 @@ +// Verification-only candidate with deliberately inverted capture behavior. +// The negative qualification campaign requires EQY to reject this design. +module flow_fixture #( + parameter DATA_WIDTH = 32 +) ( + input wire clk_i, + input wire rst_ni, + input wire enable_i, + input wire [DATA_WIDTH-1:0] data_i, + output wire [DATA_WIDTH-1:0] data_o +); + wire unused_controls = clk_i | rst_ni | enable_i; + + assign data_o = ~data_i ^ {DATA_WIDTH{unused_controls & 1'b0}}; + +endmodule diff --git a/tests/fixture-module/verif/tb/flow_fixture_four_state_tb.sv b/tests/fixture-module/verif/tb/flow_fixture_four_state_tb.sv new file mode 100644 index 0000000..0b63f73 --- /dev/null +++ b/tests/fixture-module/verif/tb/flow_fixture_four_state_tb.sv @@ -0,0 +1,31 @@ +module flow_fixture_four_state_tb; + logic control_i; + logic saw_x; + logic saw_z; + + initial begin + saw_x = 1'b0; + saw_z = 1'b0; + + control_i = 1'bx; + #1; + saw_x = $isunknown(control_i); + + control_i = 1'bz; + #1; + saw_z = $isunknown(control_i); + +`ifdef FLOW_FIXTURE_DISABLE_UNKNOWN_MONITOR + if (saw_x && saw_z) begin + $display("UNKNOWN_CONTROL_STIMULUS_REACHED"); + $finish; + end + $fatal(1, "FOUR_STATE_STIMULUS_BROKEN"); +`else + if (saw_x && saw_z) begin + $fatal(1, "UNKNOWN_CONTROL_XZ_DETECTED"); + end + $finish; +`endif + end +endmodule diff --git a/tests/fixture-multi-module/Makefile b/tests/fixture-multi-module/Makefile new file mode 100644 index 0000000..e69dca8 --- /dev/null +++ b/tests/fixture-multi-module/Makefile @@ -0,0 +1,23 @@ +SHELL := /usr/bin/env bash + +export MODULE_ROOT := $(CURDIR) +export FLOW_ROOT ?= $(abspath $(MODULE_ROOT)/../..) + +include $(FLOW_ROOT)/mk/project.mk + +.PHONY: fixture-probe + +# Cheap orchestration probe used by static tests without invoking EDA tools. +fixture-probe: + @mkdir -p "$(REPORT_DIR)/probe" "$(WORK_DIR)/probe" + @printf 'module=%s\ndesign_top=%s\nrtl_filelist=%s\nformal_config=%s\nreport_dir=%s\nwork_dir=%s\nformat_args=%s\n' \ + "$(MODULE)" "$(DESIGN_TOP)" "$(RTL_FILELIST)" "$(FORMAL_CONFIG)" \ + "$(REPORT_DIR)" "$(WORK_DIR)" "$(VERIBLE_FORMAT_ARGS)" \ + >"$(REPORT_DIR)/probe/config.txt" + @printf 'generated\n' >"$(WORK_DIR)/probe/output.txt" + @if [[ "$(FAIL_MODULE)" == "$(MODULE)" ]]; then \ + printf 'FAIL\n' >"$(REPORT_DIR)/probe/status.txt"; \ + echo "Requested fixture failure for $(MODULE)" >&2; \ + exit 1; \ + fi + @printf 'PASS\n' >"$(REPORT_DIR)/probe/status.txt" diff --git a/tests/fixture-multi-module/config/modules.json b/tests/fixture-multi-module/config/modules.json new file mode 100644 index 0000000..138ef70 --- /dev/null +++ b/tests/fixture-multi-module/config/modules.json @@ -0,0 +1,13 @@ +{ + "schema": "mosaic-modules-v1", + "include": [ + { + "name": "alpha", + "artifact_suffix": "native" + }, + { + "name": "beta", + "artifact_suffix": "native" + } + ] +} diff --git a/tests/fixture-multi-module/config/modules/alpha-flows.mk b/tests/fixture-multi-module/config/modules/alpha-flows.mk new file mode 100644 index 0000000..9098fb5 --- /dev/null +++ b/tests/fixture-multi-module/config/modules/alpha-flows.mk @@ -0,0 +1,20 @@ +FLOW_verible_lint := disabled +FLOW_verible_format := enabled +FLOW_slang_elaboration := enabled +FLOW_verilator_lint := disabled +FLOW_yosys_synthesis := disabled +FLOW_symbiyosys_formal := disabled +FLOW_eqy_equivalence := disabled +FLOW_verilator_sim := disabled +FLOW_pyuvm_open_source := disabled +FLOW_openroad := disabled +FLOW_vcs_sim := disabled +FLOW_pyuvm_commercial := disabled +FLOW_vc_lint := disabled +FLOW_vc_cdc := disabled +FLOW_sg_cdc := disabled +FLOW_sg_dft := disabled +FLOW_vc_lp := disabled +FLOW_synopsys_synthesis := disabled +FLOW_synopsys_primetime := disabled +FLOW_synopsys_primepower := disabled diff --git a/tests/fixture-multi-module/config/modules/alpha.mk b/tests/fixture-multi-module/config/modules/alpha.mk new file mode 100644 index 0000000..9e9163f --- /dev/null +++ b/tests/fixture-multi-module/config/modules/alpha.mk @@ -0,0 +1,13 @@ +export DESIGN_TOP := alpha +export TB_TOP := $(DESIGN_TOP)_tb +export FORMAL_TOP := $(DESIGN_TOP)_formal +export DUT_INSTANCE := $(TB_TOP)/dut + +export RTL_FILELIST := $(call mosaic_resolve_filelist,rtl.f) +export TB_FILELIST := $(call mosaic_resolve_filelist,tb.f) +export FORMAL_CONFIG := $(call mosaic_resolve_flow_config,symbiyosys,formal.sby) +export CONSTRAINT_DIR := $(MODULE_ROOT)/constraints + +# Alpha demonstrates a module-owned four-space formatting policy. +export VERIBLE_FORMAT_ARGS := --indentation_spaces=4 +export VERIBLE_FORMAT_PATHS := rtl/alpha.sv diff --git a/tests/fixture-multi-module/config/modules/beta-flows.mk b/tests/fixture-multi-module/config/modules/beta-flows.mk new file mode 100644 index 0000000..9098fb5 --- /dev/null +++ b/tests/fixture-multi-module/config/modules/beta-flows.mk @@ -0,0 +1,20 @@ +FLOW_verible_lint := disabled +FLOW_verible_format := enabled +FLOW_slang_elaboration := enabled +FLOW_verilator_lint := disabled +FLOW_yosys_synthesis := disabled +FLOW_symbiyosys_formal := disabled +FLOW_eqy_equivalence := disabled +FLOW_verilator_sim := disabled +FLOW_pyuvm_open_source := disabled +FLOW_openroad := disabled +FLOW_vcs_sim := disabled +FLOW_pyuvm_commercial := disabled +FLOW_vc_lint := disabled +FLOW_vc_cdc := disabled +FLOW_sg_cdc := disabled +FLOW_sg_dft := disabled +FLOW_vc_lp := disabled +FLOW_synopsys_synthesis := disabled +FLOW_synopsys_primetime := disabled +FLOW_synopsys_primepower := disabled diff --git a/tests/fixture-multi-module/config/modules/beta.mk b/tests/fixture-multi-module/config/modules/beta.mk new file mode 100644 index 0000000..7046997 --- /dev/null +++ b/tests/fixture-multi-module/config/modules/beta.mk @@ -0,0 +1,11 @@ +export DESIGN_TOP := beta +export TB_TOP := $(DESIGN_TOP)_tb +export FORMAL_TOP := $(DESIGN_TOP)_formal +export DUT_INSTANCE := $(TB_TOP)/dut + +export RTL_FILELIST := $(call mosaic_resolve_filelist,rtl.f) +export TB_FILELIST := $(call mosaic_resolve_filelist,tb.f) +export FORMAL_CONFIG := $(call mosaic_resolve_flow_config,symbiyosys,formal.sby) +export CONSTRAINT_DIR := $(MODULE_ROOT)/constraints + +export VERIBLE_FORMAT_PATHS := rtl/beta.sv diff --git a/tests/fixture-multi-module/filelists/alpha.rtl.f b/tests/fixture-multi-module/filelists/alpha.rtl.f new file mode 100644 index 0000000..b5b1eee --- /dev/null +++ b/tests/fixture-multi-module/filelists/alpha.rtl.f @@ -0,0 +1 @@ +rtl/alpha.sv diff --git a/tests/fixture-multi-module/filelists/alpha.tb.f b/tests/fixture-multi-module/filelists/alpha.tb.f new file mode 100644 index 0000000..7ea300c --- /dev/null +++ b/tests/fixture-multi-module/filelists/alpha.tb.f @@ -0,0 +1 @@ +-f filelists/alpha.rtl.f diff --git a/tests/fixture-multi-module/filelists/rtl.f b/tests/fixture-multi-module/filelists/rtl.f new file mode 100644 index 0000000..d90fdef --- /dev/null +++ b/tests/fixture-multi-module/filelists/rtl.f @@ -0,0 +1 @@ +rtl/beta.sv diff --git a/tests/fixture-multi-module/filelists/tb.f b/tests/fixture-multi-module/filelists/tb.f new file mode 100644 index 0000000..8a6dfe8 --- /dev/null +++ b/tests/fixture-multi-module/filelists/tb.f @@ -0,0 +1 @@ +-f filelists/rtl.f diff --git a/tests/fixture-multi-module/flows/symbiyosys/alpha.formal.sby b/tests/fixture-multi-module/flows/symbiyosys/alpha.formal.sby new file mode 100644 index 0000000..39d289c --- /dev/null +++ b/tests/fixture-multi-module/flows/symbiyosys/alpha.formal.sby @@ -0,0 +1,2 @@ +[options] +mode prove diff --git a/tests/fixture-multi-module/flows/symbiyosys/formal.sby b/tests/fixture-multi-module/flows/symbiyosys/formal.sby new file mode 100644 index 0000000..1faf454 --- /dev/null +++ b/tests/fixture-multi-module/flows/symbiyosys/formal.sby @@ -0,0 +1,2 @@ +[options] +mode cover diff --git a/tests/fixture-multi-module/rtl/alpha.sv b/tests/fixture-multi-module/rtl/alpha.sv new file mode 100644 index 0000000..27b8f8e --- /dev/null +++ b/tests/fixture-multi-module/rtl/alpha.sv @@ -0,0 +1,10 @@ +module alpha #( + parameter int unsigned WIDTH = 4 +) ( + input logic [WIDTH-1:0] data_i, + output logic [WIDTH-1:0] data_o +); + + assign data_o = data_i; + +endmodule diff --git a/tests/fixture-multi-module/rtl/beta.sv b/tests/fixture-multi-module/rtl/beta.sv new file mode 100644 index 0000000..3bc2a8d --- /dev/null +++ b/tests/fixture-multi-module/rtl/beta.sv @@ -0,0 +1,10 @@ +module beta #( + parameter int unsigned WIDTH = 2 +) ( + input logic [WIDTH-1:0] data_i, + output logic [WIDTH-1:0] data_o +); + + assign data_o = ~data_i; + +endmodule diff --git a/tests/fixture-parameter-profiles/Makefile b/tests/fixture-parameter-profiles/Makefile new file mode 100644 index 0000000..92b7906 --- /dev/null +++ b/tests/fixture-parameter-profiles/Makefile @@ -0,0 +1,18 @@ +SHELL := /usr/bin/env bash + +export MODULE_ROOT := $(CURDIR) +export FLOW_ROOT ?= $(abspath $(MODULE_ROOT)/../..) + +include config/design.mk +include $(FLOW_ROOT)/config/tools.mk +include $(FLOW_ROOT)/mk/module.mk + +.PHONY: profile-probe + +profile-probe: profile-evidence + @mkdir -p "$(REPORT_DIR)/probe" "$(WORK_DIR)/probe" + @if [[ "$(FAIL_PROFILE)" == "$(PROFILE)" ]]; then \ + printf 'FAIL\n' >"$(REPORT_DIR)/probe/status.txt"; \ + exit 1; \ + fi + @printf 'PASS\n' >"$(REPORT_DIR)/probe/status.txt" diff --git a/tests/fixture-parameter-profiles/config/design.mk b/tests/fixture-parameter-profiles/config/design.mk new file mode 100644 index 0000000..4c90fb8 --- /dev/null +++ b/tests/fixture-parameter-profiles/config/design.mk @@ -0,0 +1,23 @@ +export DESIGN_TOP := profile_fixture +export TB_TOP := $(DESIGN_TOP)_tb +export FORMAL_TOP := $(DESIGN_TOP)_formal +export DUT_INSTANCE := $(TB_TOP)/dut + +export RTL_FILELIST := $(MODULE_ROOT)/filelists/rtl.f +export TB_FILELIST := $(MODULE_ROOT)/filelists/tb.f +export PYUVM_FILELIST := $(RTL_FILELIST) +export PYUVM_TOP := $(DESIGN_TOP) +export PYUVM_TEST_MODULE := test_profile_fixture +export PYUVM_TEST_PATH := $(MODULE_ROOT)/verif/pyuvm +export PYUVM_COVERAGE := disabled + +export VERILATOR_WAIVER_FILE := $(MODULE_ROOT)/config/verilator_waivers.vlt +export VERIBLE_WAIVER_FILE := $(MODULE_ROOT)/config/verible_waivers.txt +export VERIBLE_RULES_FILE := $(MODULE_ROOT)/config/verible.rules +export FORMAL_CONFIG := $(MODULE_ROOT)/config/formal.sby +export EQUIVALENCE_CONFIG := $(MODULE_ROOT)/config/equivalence.eqy +export OPENROAD_CONFIG := $(MODULE_ROOT)/config/openroad.mk +export CONSTRAINT_DIR := $(MODULE_ROOT)/constraints +export PARAMETER_PROFILE_MANIFEST := $(MODULE_ROOT)/config/parameter-profiles.json +export REPORT_DIR := $(MODULE_ROOT)/reports +export WORK_DIR := $(MODULE_ROOT)/work diff --git a/tests/fixture-parameter-profiles/config/equivalence.eqy b/tests/fixture-parameter-profiles/config/equivalence.eqy new file mode 100644 index 0000000..50eec0d --- /dev/null +++ b/tests/fixture-parameter-profiles/config/equivalence.eqy @@ -0,0 +1,11 @@ +[gold] +read_verilog -sv rtl/profile_fixture.sv +prep -top profile_fixture + +[gate] +read_verilog work/yosys_synthesis/profile_fixture_netlist.v +prep -top profile_fixture + +[strategy sat] +use sat +depth 2 diff --git a/tests/fixture-parameter-profiles/config/flows.mk b/tests/fixture-parameter-profiles/config/flows.mk new file mode 100644 index 0000000..a9b6328 --- /dev/null +++ b/tests/fixture-parameter-profiles/config/flows.mk @@ -0,0 +1,20 @@ +FLOW_verible_lint := enabled +FLOW_verible_format := enabled +FLOW_slang_elaboration := enabled +FLOW_verilator_lint := enabled +FLOW_yosys_synthesis := enabled +FLOW_symbiyosys_formal := enabled +FLOW_eqy_equivalence := enabled +FLOW_verilator_sim := enabled +FLOW_pyuvm_open_source := enabled +FLOW_openroad := disabled +FLOW_vcs_sim := disabled +FLOW_pyuvm_commercial := disabled +FLOW_vc_lint := disabled +FLOW_vc_cdc := disabled +FLOW_sg_cdc := disabled +FLOW_sg_dft := disabled +FLOW_vc_lp := disabled +FLOW_synopsys_synthesis := disabled +FLOW_synopsys_primetime := disabled +FLOW_synopsys_primepower := disabled diff --git a/tests/fixture-parameter-profiles/config/formal.sby b/tests/fixture-parameter-profiles/config/formal.sby new file mode 100644 index 0000000..4075ad8 --- /dev/null +++ b/tests/fixture-parameter-profiles/config/formal.sby @@ -0,0 +1,14 @@ +[options] +mode prove +depth 2 + +[engines] +smtbmc bitwuzla + +[script] +read -formal -sv profile_fixture.sv profile_fixture_formal.sv +prep -top profile_fixture_formal + +[files] +rtl/profile_fixture.sv +verif/formal/profile_fixture_formal.sv diff --git a/tests/fixture-parameter-profiles/config/openroad.mk b/tests/fixture-parameter-profiles/config/openroad.mk new file mode 100644 index 0000000..42da377 --- /dev/null +++ b/tests/fixture-parameter-profiles/config/openroad.mk @@ -0,0 +1 @@ +# OpenROAD is intentionally outside this fixture's profile flow policy. diff --git a/tests/fixture-parameter-profiles/config/parameter-profiles.json b/tests/fixture-parameter-profiles/config/parameter-profiles.json new file mode 100644 index 0000000..2a4702b --- /dev/null +++ b/tests/fixture-parameter-profiles/config/parameter-profiles.json @@ -0,0 +1,78 @@ +{ + "schema": "mosaic-parameter-profiles-v1", + "include": [ + { + "name": "width_min", + "parameters": { + "FEATURE_INVERT": false, + "WIDTH": 1 + }, + "flows": [ + "verible_lint", + "verible_format", + "slang_elaboration", + "verilator_lint", + "yosys_synthesis", + "symbiyosys_formal", + "eqy_equivalence", + "verilator_sim", + "pyuvm_open_source" + ] + }, + { + "name": "nominal", + "parameters": { + "FEATURE_INVERT": false, + "WIDTH": 8 + }, + "flows": [ + "verible_lint", + "verible_format", + "slang_elaboration", + "verilator_lint", + "yosys_synthesis", + "symbiyosys_formal", + "eqy_equivalence", + "verilator_sim", + "pyuvm_open_source" + ] + }, + { + "name": "feature_invert", + "parameters": { + "FEATURE_INVERT": true, + "WIDTH": 5 + }, + "flows": [ + "verible_lint", + "verible_format", + "slang_elaboration", + "verilator_lint", + "yosys_synthesis", + "symbiyosys_formal", + "eqy_equivalence", + "verilator_sim", + "pyuvm_open_source" + ], + "tops": { + "design": "profile_fixture", + "formal": "profile_fixture_formal", + "pyuvm": "profile_fixture", + "testbench": "profile_fixture_tb" + } + }, + { + "name": "elaboration_only", + "parameters": { + "FEATURE_INVERT": false, + "WIDTH": 3 + }, + "flows": [ + "verible_lint", + "verible_format", + "slang_elaboration", + "verilator_lint" + ] + } + ] +} diff --git a/tests/fixture-parameter-profiles/config/verible.rules b/tests/fixture-parameter-profiles/config/verible.rules new file mode 100644 index 0000000..406dba3 --- /dev/null +++ b/tests/fixture-parameter-profiles/config/verible.rules @@ -0,0 +1 @@ +# Empty means Verible's default rule set. diff --git a/tests/fixture-parameter-profiles/config/verible_waivers.txt b/tests/fixture-parameter-profiles/config/verible_waivers.txt new file mode 100644 index 0000000..8ca064c --- /dev/null +++ b/tests/fixture-parameter-profiles/config/verible_waivers.txt @@ -0,0 +1 @@ +# No Verible waivers are required by the parameter-profile fixture. diff --git a/tests/fixture-parameter-profiles/config/verilator_waivers.vlt b/tests/fixture-parameter-profiles/config/verilator_waivers.vlt new file mode 100644 index 0000000..710b5ae --- /dev/null +++ b/tests/fixture-parameter-profiles/config/verilator_waivers.vlt @@ -0,0 +1 @@ +`verilator_config diff --git a/tests/fixture-parameter-profiles/filelists/rtl.f b/tests/fixture-parameter-profiles/filelists/rtl.f new file mode 100644 index 0000000..31dc27f --- /dev/null +++ b/tests/fixture-parameter-profiles/filelists/rtl.f @@ -0,0 +1 @@ +rtl/profile_fixture.sv diff --git a/tests/fixture-parameter-profiles/filelists/tb.f b/tests/fixture-parameter-profiles/filelists/tb.f new file mode 100644 index 0000000..a03f9fd --- /dev/null +++ b/tests/fixture-parameter-profiles/filelists/tb.f @@ -0,0 +1,2 @@ +-f filelists/rtl.f +verif/tb/profile_fixture_tb.sv diff --git a/tests/fixture-parameter-profiles/rtl/profile_fixture.sv b/tests/fixture-parameter-profiles/rtl/profile_fixture.sv new file mode 100644 index 0000000..515e08c --- /dev/null +++ b/tests/fixture-parameter-profiles/rtl/profile_fixture.sv @@ -0,0 +1,11 @@ +module profile_fixture #( + parameter int unsigned WIDTH = 8, + parameter bit FEATURE_INVERT = 1'b0 +) ( + input logic [WIDTH-1:0] data_i, + output logic [WIDTH-1:0] data_o +); + + assign data_o = FEATURE_INVERT ? ~data_i : data_i; + +endmodule diff --git a/tests/fixture-parameter-profiles/verif/formal/profile_fixture_formal.sv b/tests/fixture-parameter-profiles/verif/formal/profile_fixture_formal.sv new file mode 100644 index 0000000..8393de7 --- /dev/null +++ b/tests/fixture-parameter-profiles/verif/formal/profile_fixture_formal.sv @@ -0,0 +1,20 @@ +module profile_fixture_formal #( + parameter int unsigned WIDTH = 8, + parameter bit FEATURE_INVERT = 1'b0 +); + + (* anyseq *)logic [WIDTH-1:0] data_i; + logic [WIDTH-1:0] data_o; + + profile_fixture #( + .WIDTH(WIDTH), + .FEATURE_INVERT(FEATURE_INVERT) + ) dut ( + .* + ); + + always_comb begin + assert (data_o == (FEATURE_INVERT ? ~data_i : data_i)); + end + +endmodule diff --git a/tests/fixture-parameter-profiles/verif/pyuvm/test_profile_fixture.py b/tests/fixture-parameter-profiles/verif/pyuvm/test_profile_fixture.py new file mode 100644 index 0000000..f6ece33 --- /dev/null +++ b/tests/fixture-parameter-profiles/verif/pyuvm/test_profile_fixture.py @@ -0,0 +1,28 @@ +"""PyUVM qualification for every parameterized fixture elaboration.""" + +from __future__ import annotations + +import json +import os + +import cocotb +from cocotb.triggers import Timer +from pyuvm import test, uvm_test + + +@test() +class ProfileFixtureTest(uvm_test): + """Check width and feature behavior using the selected profile evidence.""" + + async def run_phase(self) -> None: + """Drive all ones and check the selected feature implementation.""" + self.raise_objection() + parameters = json.loads(os.environ["PROFILE_PARAMETERS_JSON"]) + width = int(parameters["WIDTH"]) + invert = bool(parameters["FEATURE_INVERT"]) + mask = (1 << width) - 1 + cocotb.top.data_i.value = mask + await Timer(1, unit="ns") + expected = 0 if invert else mask + assert int(cocotb.top.data_o.value) == expected + self.drop_objection() diff --git a/tests/fixture-parameter-profiles/verif/tb/profile_fixture_tb.sv b/tests/fixture-parameter-profiles/verif/tb/profile_fixture_tb.sv new file mode 100644 index 0000000..82abdec --- /dev/null +++ b/tests/fixture-parameter-profiles/verif/tb/profile_fixture_tb.sv @@ -0,0 +1,26 @@ +module profile_fixture_tb #( + parameter int unsigned WIDTH = 8, + parameter bit FEATURE_INVERT = 1'b0 +); + + logic [WIDTH-1:0] data_i; + logic [WIDTH-1:0] data_o; + logic [WIDTH-1:0] expected; + + profile_fixture #( + .WIDTH(WIDTH), + .FEATURE_INVERT(FEATURE_INVERT) + ) dut ( + .* + ); + + initial begin + data_i = '1; + #1ns; + expected = FEATURE_INVERT ? '0 : '1; + assert (data_o == expected) + else $fatal(1, "Unexpected profile output: %h", data_o); + $finish; + end + +endmodule diff --git a/tests/fixtures/qualification_driver.py b/tests/fixtures/qualification_driver.py new file mode 100755 index 0000000..c7e4727 --- /dev/null +++ b/tests/fixtures/qualification_driver.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python3 +"""Emit deterministic qualification outcomes without requiring an EDA tool.""" + +from __future__ import annotations + +import argparse +import sys + + +parser = argparse.ArgumentParser() +parser.add_argument("--diagnostic", required=True) +parser.add_argument("--exit-code", required=True, type=int) +arguments = parser.parse_args() + +print(arguments.diagnostic) +sys.exit(arguments.exit_code) diff --git a/tests/fixtures/static-intent/config/always-on.json b/tests/fixtures/static-intent/config/always-on.json new file mode 100644 index 0000000..c85de58 --- /dev/null +++ b/tests/fixtures/static-intent/config/always-on.json @@ -0,0 +1,16 @@ +{ + "schema": "mosaic-static-intent-v1", + "upf": { + "path": "upf/always-on.upf", + "top": "intent_fixture", + "mode": "always_on", + "required": { + "power_domains": ["PD_CORE"], + "supply_ports": ["VDD", "VSS"], + "supply_nets": ["VDD", "VSS"], + "supply_connections": ["VDD", "VSS"], + "domain_supplies": ["PD_CORE"] + }, + "forbidden_strategies": [] + } +} diff --git a/tests/fixtures/static-intent/config/broad-exception.json b/tests/fixtures/static-intent/config/broad-exception.json new file mode 100644 index 0000000..cf3cda5 --- /dev/null +++ b/tests/fixtures/static-intent/config/broad-exception.json @@ -0,0 +1,13 @@ +{ + "schema": "mosaic-static-intent-v1", + "sdc": { + "profiles": [ + { + "name": "broad", + "path": "sdc/broad-exception.sdc", + "kind": "sequential", + "expected_ports": {"clock": ["clk"]} + } + ] + } +} diff --git a/tests/fixtures/static-intent/config/complete.json b/tests/fixtures/static-intent/config/complete.json new file mode 100644 index 0000000..98d12c0 --- /dev/null +++ b/tests/fixtures/static-intent/config/complete.json @@ -0,0 +1,107 @@ +{ + "schema": "mosaic-static-intent-v1", + "sdc": { + "profiles": [ + { + "name": "synthesis", + "path": "sdc/sequential.sdc", + "kind": "sequential", + "expected_ports": { + "clock": ["clk"], + "input_delay": ["data", "enable"], + "output_delay": ["result"] + }, + "clocks": [{"name": "core_clk", "period": 10.0, "port": "clk"}], + "intentional_exceptions": [ + {"command": "set_false_path", "from_ports": ["rst_n"], "to_ports": []} + ] + }, + { + "name": "physical", + "path": "sdc/physical.sdc", + "kind": "sequential", + "expected_ports": { + "clock": ["clk"], + "input_delay": ["data", "enable"], + "output_delay": ["result"] + }, + "clocks": [{"name": "core_clk", "period": 10.0, "port": "clk"}], + "intentional_exceptions": [ + {"command": "set_false_path", "from_ports": ["rst_n"], "to_ports": []} + ] + }, + { + "name": "combinational", + "path": "sdc/combinational.sdc", + "kind": "combinational", + "expected_ports": {} + }, + { + "name": "clock_gate", + "path": "sdc/clock-gate.sdc", + "kind": "clock_gating", + "expected_ports": { + "clock": ["clk"], + "input_delay": ["enable", "test_enable"] + }, + "clocks": [{"name": "source_clk", "period": 10.0, "port": "clk"}], + "generated_clocks": [ + { + "name": "gated_clk", + "source_port": "clk", + "output_port": "gated_clk", + "combinational": true + } + ] + }, + { + "name": "reset_synchronizer", + "path": "sdc/reset-synchronizer.sdc", + "kind": "reset_synchronizer", + "expected_ports": { + "clock": ["clk"], + "output_delay": ["rst_sync_n"] + }, + "clocks": [{"name": "destination_clk", "period": 10.0, "port": "clk"}] + } + ], + "consistency": [ + { + "profiles": ["synthesis", "physical"], + "commands": [ + "create_clock", + "set_clock_uncertainty", + "set_input_delay", + "set_output_delay", + "set_false_path" + ] + } + ] + }, + "upf": { + "path": "upf/retention.upf", + "top": "retention_fixture", + "mode": "boundary", + "required": { + "power_domains": ["PD_RETENTION"], + "supply_ports": ["VDD_MAIN", "VDD_RETENTION", "VSS"], + "supply_nets": ["VDD_MAIN", "VDD_RETENTION", "VSS"], + "supply_connections": ["VDD_MAIN", "VDD_RETENTION", "VSS"], + "domain_supplies": ["PD_RETENTION"], + "port_states": [ + "VDD_MAIN.MAIN_ON", + "VDD_MAIN.MAIN_OFF", + "VDD_RETENTION.RETENTION_ON", + "VDD_RETENTION.RETENTION_OFF" + ], + "power_state_tables": ["PST_RETENTION"], + "power_states": [ + "PST_RETENTION.BOTH_ON", + "PST_RETENTION.RETAINED", + "PST_RETENTION.ALL_OFF" + ], + "retention": ["RETENTION_REGS"] + }, + "forbidden_strategies": ["isolation", "level_shifting", "power_switch"] + } +} diff --git a/tests/fixtures/static-intent/config/conflicting-command.json b/tests/fixtures/static-intent/config/conflicting-command.json new file mode 100644 index 0000000..84e6d03 --- /dev/null +++ b/tests/fixtures/static-intent/config/conflicting-command.json @@ -0,0 +1,13 @@ +{ + "schema": "mosaic-static-intent-v1", + "sdc": { + "profiles": [ + { + "name": "conflicting", + "path": "sdc/conflicting-command.sdc", + "kind": "sequential", + "expected_ports": {"clock": ["clk"]} + } + ] + } +} diff --git a/tests/fixtures/static-intent/config/duplicate-command.json b/tests/fixtures/static-intent/config/duplicate-command.json new file mode 100644 index 0000000..6a573b7 --- /dev/null +++ b/tests/fixtures/static-intent/config/duplicate-command.json @@ -0,0 +1,13 @@ +{ + "schema": "mosaic-static-intent-v1", + "sdc": { + "profiles": [ + { + "name": "duplicate", + "path": "sdc/duplicate-command.sdc", + "kind": "sequential", + "expected_ports": {"clock": ["clk"]} + } + ] + } +} diff --git a/tests/fixtures/static-intent/config/forbidden-strategy.json b/tests/fixtures/static-intent/config/forbidden-strategy.json new file mode 100644 index 0000000..ddbfcdb --- /dev/null +++ b/tests/fixtures/static-intent/config/forbidden-strategy.json @@ -0,0 +1,10 @@ +{ + "schema": "mosaic-static-intent-v1", + "upf": { + "path": "upf/forbidden-strategy.upf", + "top": "intent_fixture", + "mode": "always_on", + "required": {"power_domains": ["PD_CORE"]}, + "forbidden_strategies": ["isolation"] + } +} diff --git a/tests/fixtures/static-intent/config/incomplete-max-delay.json b/tests/fixtures/static-intent/config/incomplete-max-delay.json new file mode 100644 index 0000000..8d7cb94 --- /dev/null +++ b/tests/fixtures/static-intent/config/incomplete-max-delay.json @@ -0,0 +1,12 @@ +{ + "schema": "mosaic-static-intent-v1", + "sdc": { + "profiles": [ + { + "name": "incomplete_path", + "path": "sdc/incomplete-max-delay.sdc", + "kind": "combinational" + } + ] + } +} diff --git a/tests/fixtures/static-intent/config/incomplete-power-state.json b/tests/fixtures/static-intent/config/incomplete-power-state.json new file mode 100644 index 0000000..a01627a --- /dev/null +++ b/tests/fixtures/static-intent/config/incomplete-power-state.json @@ -0,0 +1,13 @@ +{ + "schema": "mosaic-static-intent-v1", + "upf": { + "path": "upf/incomplete-power-state.upf", + "top": "retention_fixture", + "mode": "boundary", + "required": { + "power_domains": ["PD_RETENTION"], + "power_state_tables": ["PST_RETENTION"] + }, + "forbidden_strategies": [] + } +} diff --git a/tests/fixtures/static-intent/config/isolation.json b/tests/fixtures/static-intent/config/isolation.json new file mode 100644 index 0000000..7010c1d --- /dev/null +++ b/tests/fixtures/static-intent/config/isolation.json @@ -0,0 +1,17 @@ +{ + "schema": "mosaic-static-intent-v1", + "upf": { + "path": "upf/isolation.upf", + "top": "isolation_fixture", + "mode": "boundary", + "required": { + "power_domains": ["PD_ISO"], + "supply_ports": ["VDD", "VSS"], + "supply_nets": ["VDD", "VSS"], + "supply_connections": ["VDD", "VSS"], + "domain_supplies": ["PD_ISO"], + "isolation": ["ISO_OUTPUTS"] + }, + "forbidden_strategies": ["level_shifting", "retention", "power_switch"] + } +} diff --git a/tests/fixtures/static-intent/config/level-shifter.json b/tests/fixtures/static-intent/config/level-shifter.json new file mode 100644 index 0000000..d0aece4 --- /dev/null +++ b/tests/fixtures/static-intent/config/level-shifter.json @@ -0,0 +1,18 @@ +{ + "schema": "mosaic-static-intent-v1", + "upf": { + "path": "upf/level-shifter.upf", + "top": "level_shifter_fixture", + "mode": "boundary", + "required": { + "power_domains": ["PD_LS"], + "supply_ports": ["VDD_LOW", "VDD_HIGH", "VSS"], + "supply_nets": ["VDD_LOW", "VDD_HIGH", "VSS"], + "supply_connections": ["VDD_LOW", "VDD_HIGH", "VSS"], + "domain_supplies": ["PD_LS"], + "port_attribute_ports": ["data_out"], + "level_shifting": ["LS_OUTPUTS"] + }, + "forbidden_strategies": ["isolation", "retention", "power_switch"] + } +} diff --git a/tests/fixtures/static-intent/config/missing-constraint.json b/tests/fixtures/static-intent/config/missing-constraint.json new file mode 100644 index 0000000..bd8f540 --- /dev/null +++ b/tests/fixtures/static-intent/config/missing-constraint.json @@ -0,0 +1,13 @@ +{ + "schema": "mosaic-static-intent-v1", + "sdc": { + "profiles": [ + { + "name": "missing", + "path": "sdc/missing-constraint.sdc", + "kind": "sequential", + "expected_ports": {"clock": ["clk"], "output_delay": ["result"]} + } + ] + } +} diff --git a/tests/fixtures/static-intent/config/profile-mismatch.json b/tests/fixtures/static-intent/config/profile-mismatch.json new file mode 100644 index 0000000..66d33c9 --- /dev/null +++ b/tests/fixtures/static-intent/config/profile-mismatch.json @@ -0,0 +1,29 @@ +{ + "schema": "mosaic-static-intent-v1", + "sdc": { + "profiles": [ + { + "name": "synthesis", + "path": "sdc/sequential.sdc", + "kind": "sequential", + "intentional_exceptions": [ + {"command": "set_false_path", "from_ports": ["rst_n"], "to_ports": []} + ] + }, + { + "name": "physical", + "path": "sdc/physical-mismatch.sdc", + "kind": "sequential", + "intentional_exceptions": [ + {"command": "set_false_path", "from_ports": ["rst_n"], "to_ports": []} + ] + } + ], + "consistency": [ + { + "profiles": ["synthesis", "physical"], + "commands": ["create_clock", "set_clock_uncertainty", "set_input_delay", "set_output_delay"] + } + ] + } +} diff --git a/tests/fixtures/static-intent/config/unsupported-command.json b/tests/fixtures/static-intent/config/unsupported-command.json new file mode 100644 index 0000000..297a3c3 --- /dev/null +++ b/tests/fixtures/static-intent/config/unsupported-command.json @@ -0,0 +1,12 @@ +{ + "schema": "mosaic-static-intent-v1", + "sdc": { + "profiles": [ + { + "name": "unsupported", + "path": "sdc/unsupported-command.sdc", + "kind": "sequential" + } + ] + } +} diff --git a/tests/fixtures/static-intent/sdc/broad-exception.sdc b/tests/fixtures/static-intent/sdc/broad-exception.sdc new file mode 100644 index 0000000..f9e63e6 --- /dev/null +++ b/tests/fixtures/static-intent/sdc/broad-exception.sdc @@ -0,0 +1,5 @@ +create_clock -name core_clk -period 10.0 [get_ports clk] +set_clock_uncertainty 0.1 [get_clocks core_clk] +set_input_delay 0.5 -clock core_clk [all_inputs] +set_output_delay 0.5 -clock core_clk [all_outputs] +set_false_path -from [all_inputs] diff --git a/tests/fixtures/static-intent/sdc/clock-gate.sdc b/tests/fixtures/static-intent/sdc/clock-gate.sdc new file mode 100644 index 0000000..3e62449 --- /dev/null +++ b/tests/fixtures/static-intent/sdc/clock-gate.sdc @@ -0,0 +1,4 @@ +create_clock -name source_clk -period 10.0 [get_ports clk] +create_generated_clock -name gated_clk -source [get_ports clk] -combinational [get_ports gated_clk] +set_clock_uncertainty 0.1 [get_clocks source_clk] +set_input_delay 0.5 -clock source_clk [get_ports {enable test_enable}] diff --git a/tests/fixtures/static-intent/sdc/combinational.sdc b/tests/fixtures/static-intent/sdc/combinational.sdc new file mode 100644 index 0000000..34c3de6 --- /dev/null +++ b/tests/fixtures/static-intent/sdc/combinational.sdc @@ -0,0 +1,3 @@ +set_max_delay 5.0 -from [all_inputs] -to [all_outputs] +set_input_transition 0.1 [all_inputs] +set_load 0.01 [all_outputs] diff --git a/tests/fixtures/static-intent/sdc/conflicting-command.sdc b/tests/fixtures/static-intent/sdc/conflicting-command.sdc new file mode 100644 index 0000000..0d5f27e --- /dev/null +++ b/tests/fixtures/static-intent/sdc/conflicting-command.sdc @@ -0,0 +1,5 @@ +create_clock -name core_clk -period 10.0 [get_ports clk] +create_clock -name core_clk -period 8.0 [get_ports clk] +set_clock_uncertainty 0.1 [get_clocks core_clk] +set_input_delay 0.5 -clock core_clk [all_inputs] +set_output_delay 0.5 -clock core_clk [all_outputs] diff --git a/tests/fixtures/static-intent/sdc/duplicate-command.sdc b/tests/fixtures/static-intent/sdc/duplicate-command.sdc new file mode 100644 index 0000000..ec066ee --- /dev/null +++ b/tests/fixtures/static-intent/sdc/duplicate-command.sdc @@ -0,0 +1,5 @@ +create_clock -name core_clk -period 10.0 [get_ports clk] +set_clock_uncertainty 0.1 [get_clocks core_clk] +set_clock_uncertainty 0.1 [get_clocks core_clk] +set_input_delay 0.5 -clock core_clk [all_inputs] +set_output_delay 0.5 -clock core_clk [all_outputs] diff --git a/tests/fixtures/static-intent/sdc/incomplete-max-delay.sdc b/tests/fixtures/static-intent/sdc/incomplete-max-delay.sdc new file mode 100644 index 0000000..d6c0b62 --- /dev/null +++ b/tests/fixtures/static-intent/sdc/incomplete-max-delay.sdc @@ -0,0 +1 @@ +set_max_delay 5.0 -from [get_ports data_i] -to [all_outputs] diff --git a/tests/fixtures/static-intent/sdc/missing-constraint.sdc b/tests/fixtures/static-intent/sdc/missing-constraint.sdc new file mode 100644 index 0000000..5bd00f4 --- /dev/null +++ b/tests/fixtures/static-intent/sdc/missing-constraint.sdc @@ -0,0 +1,3 @@ +create_clock -name core_clk -period 10.0 [get_ports clk] +set_clock_uncertainty 0.1 [get_clocks core_clk] +set_input_delay 0.5 -clock core_clk [all_inputs] diff --git a/tests/fixtures/static-intent/sdc/physical-mismatch.sdc b/tests/fixtures/static-intent/sdc/physical-mismatch.sdc new file mode 100644 index 0000000..80193d2 --- /dev/null +++ b/tests/fixtures/static-intent/sdc/physical-mismatch.sdc @@ -0,0 +1,5 @@ +create_clock -name core_clk -period 8.0 [get_ports clk] +set_clock_uncertainty 0.1 [get_clocks core_clk] +set_input_delay 0.5 -clock core_clk [remove_from_collection [all_inputs] [get_ports clk]] +set_output_delay 0.5 -clock core_clk [all_outputs] +set_false_path -from [get_ports rst_n] diff --git a/tests/fixtures/static-intent/sdc/physical.sdc b/tests/fixtures/static-intent/sdc/physical.sdc new file mode 100644 index 0000000..fbcf269 --- /dev/null +++ b/tests/fixtures/static-intent/sdc/physical.sdc @@ -0,0 +1,5 @@ +create_clock -name core_clk -period 10.0 [get_ports clk] +set_clock_uncertainty 0.1 [get_clocks core_clk] +set_input_delay 0.5 -clock core_clk [remove_from_collection [all_inputs] [get_ports clk]] +set_output_delay 0.5 -clock core_clk [all_outputs] +set_false_path -from [get_ports rst_n] diff --git a/tests/fixtures/static-intent/sdc/reset-synchronizer.sdc b/tests/fixtures/static-intent/sdc/reset-synchronizer.sdc new file mode 100644 index 0000000..6564cb4 --- /dev/null +++ b/tests/fixtures/static-intent/sdc/reset-synchronizer.sdc @@ -0,0 +1,3 @@ +create_clock -name destination_clk -period 10.0 [get_ports clk] +set_clock_uncertainty 0.1 [get_clocks destination_clk] +set_output_delay 0.5 -clock destination_clk [get_ports rst_sync_n] diff --git a/tests/fixtures/static-intent/sdc/sequential.sdc b/tests/fixtures/static-intent/sdc/sequential.sdc new file mode 100644 index 0000000..fbcf269 --- /dev/null +++ b/tests/fixtures/static-intent/sdc/sequential.sdc @@ -0,0 +1,5 @@ +create_clock -name core_clk -period 10.0 [get_ports clk] +set_clock_uncertainty 0.1 [get_clocks core_clk] +set_input_delay 0.5 -clock core_clk [remove_from_collection [all_inputs] [get_ports clk]] +set_output_delay 0.5 -clock core_clk [all_outputs] +set_false_path -from [get_ports rst_n] diff --git a/tests/fixtures/static-intent/sdc/unsupported-command.sdc b/tests/fixtures/static-intent/sdc/unsupported-command.sdc new file mode 100644 index 0000000..5fe210c --- /dev/null +++ b/tests/fixtures/static-intent/sdc/unsupported-command.sdc @@ -0,0 +1,2 @@ +create_clock -name core_clk -period 10.0 [get_ports clk] +set_operating_conditions typical diff --git a/tests/fixtures/static-intent/upf/always-on.upf b/tests/fixtures/static-intent/upf/always-on.upf new file mode 100644 index 0000000..32835a6 --- /dev/null +++ b/tests/fixtures/static-intent/upf/always-on.upf @@ -0,0 +1,9 @@ +set_design_top intent_fixture +create_power_domain PD_CORE -include_scope +create_supply_port VDD -domain PD_CORE +create_supply_port VSS -domain PD_CORE +create_supply_net VDD -domain PD_CORE +create_supply_net VSS -domain PD_CORE +connect_supply_net VDD -ports VDD +connect_supply_net VSS -ports VSS +set_domain_supply_net PD_CORE -primary_power_net VDD -primary_ground_net VSS diff --git a/tests/fixtures/static-intent/upf/forbidden-strategy.upf b/tests/fixtures/static-intent/upf/forbidden-strategy.upf new file mode 100644 index 0000000..afbb157 --- /dev/null +++ b/tests/fixtures/static-intent/upf/forbidden-strategy.upf @@ -0,0 +1,10 @@ +set_design_top intent_fixture +create_power_domain PD_CORE -include_scope +create_supply_port VDD -domain PD_CORE +create_supply_port VSS -domain PD_CORE +create_supply_net VDD -domain PD_CORE +create_supply_net VSS -domain PD_CORE +connect_supply_net VDD -ports VDD +connect_supply_net VSS -ports VSS +set_domain_supply_net PD_CORE -primary_power_net VDD -primary_ground_net VSS +set_isolation ISO_UNEXPECTED -domain PD_CORE -applies_to outputs -clamp_value 0 diff --git a/tests/fixtures/static-intent/upf/incomplete-power-state.upf b/tests/fixtures/static-intent/upf/incomplete-power-state.upf new file mode 100644 index 0000000..136d78b --- /dev/null +++ b/tests/fixtures/static-intent/upf/incomplete-power-state.upf @@ -0,0 +1,15 @@ +set_design_top retention_fixture +create_power_domain PD_RETENTION -include_scope +create_supply_port VDD_MAIN -domain PD_RETENTION +create_supply_port VDD_RETENTION -domain PD_RETENTION +create_supply_port VSS -domain PD_RETENTION +create_supply_net VDD_MAIN -domain PD_RETENTION +create_supply_net VDD_RETENTION -domain PD_RETENTION +create_supply_net VSS -domain PD_RETENTION +connect_supply_net VDD_MAIN -ports VDD_MAIN +connect_supply_net VDD_RETENTION -ports VDD_RETENTION +connect_supply_net VSS -ports VSS +set_domain_supply_net PD_RETENTION -primary_power_net VDD_MAIN -primary_ground_net VSS +add_port_state VDD_MAIN -state {MAIN_ON 0.8} -state {MAIN_OFF off} +create_pst PST_RETENTION -supplies {VDD_MAIN VDD_RETENTION} +add_pst_state RETAINED -pst PST_RETENTION -state {MAIN_OFF} diff --git a/tests/fixtures/static-intent/upf/isolation.upf b/tests/fixtures/static-intent/upf/isolation.upf new file mode 100644 index 0000000..837bc37 --- /dev/null +++ b/tests/fixtures/static-intent/upf/isolation.upf @@ -0,0 +1,11 @@ +set_design_top isolation_fixture +create_power_domain PD_ISO -include_scope +create_supply_port VDD -domain PD_ISO +create_supply_port VSS -domain PD_ISO +create_supply_net VDD -domain PD_ISO +create_supply_net VSS -domain PD_ISO +connect_supply_net VDD -ports VDD +connect_supply_net VSS -ports VSS +set_domain_supply_net PD_ISO -primary_power_net VDD -primary_ground_net VSS +set_isolation ISO_OUTPUTS -domain PD_ISO -applies_to outputs -clamp_value 0 -isolation_power_net VDD -isolation_ground_net VSS -location self +set_isolation_control ISO_OUTPUTS -domain PD_ISO -isolation_signal {isolate high} -isolation_sense high diff --git a/tests/fixtures/static-intent/upf/level-shifter.upf b/tests/fixtures/static-intent/upf/level-shifter.upf new file mode 100644 index 0000000..364d935 --- /dev/null +++ b/tests/fixtures/static-intent/upf/level-shifter.upf @@ -0,0 +1,14 @@ +set_design_top level_shifter_fixture +create_power_domain PD_LS -include_scope +create_supply_port VDD_LOW -domain PD_LS +create_supply_port VDD_HIGH -domain PD_LS +create_supply_port VSS -domain PD_LS +create_supply_net VDD_LOW -domain PD_LS +create_supply_net VDD_HIGH -domain PD_LS +create_supply_net VSS -domain PD_LS +connect_supply_net VDD_LOW -ports VDD_LOW +connect_supply_net VDD_HIGH -ports VDD_HIGH +connect_supply_net VSS -ports VSS +set_domain_supply_net PD_LS -primary_power_net VDD_LOW -primary_ground_net VSS -secondary_power_net VDD_HIGH +set_port_attributes -ports {data_out} -driver_supply VDD_LOW -receiver_supply VDD_HIGH +set_level_shifter LS_OUTPUTS -domain PD_LS -applies_to outputs -rule low_to_high -location self diff --git a/tests/fixtures/static-intent/upf/retention.upf b/tests/fixtures/static-intent/upf/retention.upf new file mode 100644 index 0000000..146eb06 --- /dev/null +++ b/tests/fixtures/static-intent/upf/retention.upf @@ -0,0 +1,21 @@ +set_design_top retention_fixture +create_power_domain PD_RETENTION -include_scope +create_supply_port VDD_MAIN -domain PD_RETENTION +create_supply_port VDD_RETENTION -domain PD_RETENTION +create_supply_port VSS -domain PD_RETENTION +create_supply_net VDD_MAIN -domain PD_RETENTION +create_supply_net VDD_RETENTION -domain PD_RETENTION +create_supply_net VSS -domain PD_RETENTION +connect_supply_net VDD_MAIN -ports VDD_MAIN +connect_supply_net VDD_RETENTION -ports VDD_RETENTION +connect_supply_net VSS -ports VSS +set_domain_supply_net PD_RETENTION -primary_power_net VDD_MAIN -primary_ground_net VSS +add_port_state VDD_MAIN -state {MAIN_ON 0.8} -state {MAIN_OFF off} +add_port_state VDD_RETENTION -state {RETENTION_ON 0.8} -state {RETENTION_OFF off} +create_pst PST_RETENTION -supplies {VDD_MAIN VDD_RETENTION} +add_pst_state BOTH_ON -pst PST_RETENTION -state {MAIN_ON RETENTION_ON} +add_pst_state RETAINED -pst PST_RETENTION -state {MAIN_OFF RETENTION_ON} +add_pst_state ALL_OFF -pst PST_RETENTION -state {MAIN_OFF RETENTION_OFF} +set_retention RETENTION_REGS -domain PD_RETENTION \ + -retention_power_net VDD_RETENTION -retention_ground_net VSS \ + -save_signal {save high} -restore_signal {restore high} diff --git a/tests/test_coverage_qualification.sh b/tests/test_coverage_qualification.sh new file mode 100755 index 0000000..8866024 --- /dev/null +++ b/tests/test_coverage_qualification.sh @@ -0,0 +1,207 @@ +#!/usr/bin/env bash +set -euo pipefail + +flow_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +fixture_root="$(mktemp -d)" +trap 'rm -rf "${fixture_root}"' EXIT +mkdir -p "${fixture_root}/rtl" "${fixture_root}/evidence" "${fixture_root}/reports" +printf 'module dut; endmodule\n' > "${fixture_root}/rtl/dut.sv" + +python3 - "${fixture_root}" <<'PY' +import json +import pathlib +import sys + +root = pathlib.Path(sys.argv[1]) +tag = "\x01" +separator = "\x02" + + +def native_record(metric, line, name, hits): + fields = {"f": "rtl/dut.sv", "l": str(line), "t": metric, "o": name} + tagged = "".join(f"{tag}{key}{separator}{value}" for key, value in fields.items()) + return f"C '{tagged}' {hits}\n" + + +native = "# SystemC::Coverage-3\n" +native += native_record("line", 1, "block", 2) +native += native_record("line", 2, "block", 1) +native += native_record("branch", 3, "if", 1) +native += native_record("branch", 3, "else", 1) +native += native_record("toggle", 4, "ready:0->1", 1) +native += native_record("toggle", 4, "ready:1->0", 0) +native += native_record("user", 5, "transaction_seen", 3) +(root / "evidence" / "coverage.dat").write_text(native, encoding="latin-1") +deficient = "# SystemC::Coverage-3\n" +deficient += native_record("line", 1, "block", 1) +deficient += native_record("line", 2, "block", 0) +deficient += native_record("branch", 3, "if", 1) +deficient += native_record("branch", 3, "else", 0) +deficient += native_record("toggle", 4, "ready:0->1", 1) +deficient += native_record("toggle", 4, "ready:1->0", 0) +deficient += native_record("user", 5, "transaction_seen", 0) +(root / "evidence" / "deficient.dat").write_text(deficient, encoding="latin-1") +(root / "evidence" / "coverage.info").write_text( + "TN:test\nSF:rtl/dut.sv\nDA:1,2\nDA:2,1\n" + "BRDA:3,0,if,1\nBRDA:3,0,else,1\n" + "BRDA:4,0,ready:0->1,1\nBRDA:4,0,ready:1->0,0\nend_of_record\n", + encoding="utf-8", +) + +base = { + "schema": "mosaic-coverage-policy-v1", + "scope": {"include": ["rtl/*.sv"]}, + "thresholds": {"line": 100, "branch": 100, "toggle": 50, "user": 100}, + "coverpoints": [{"name": "transaction_seen", "minimum_hits": 2}], + "exclusions": [], + "formal": {"required": False}, +} +(root / "policy.json").write_text(json.dumps(base), encoding="utf-8") + +complete = json.loads(json.dumps(base)) +complete["thresholds"]["toggle"] = 100 +complete["exclusions"] = [{ + "source": "rtl/dut.sv", + "metric": "toggle", + "reason": "The protocol never drives a falling edge in this mode.", + "owner": "verification", + "scope": {"line_start": 4, "name": "ready:1->0"}, +}] +(root / "complete.json").write_text(json.dumps(complete), encoding="utf-8") + +missing = json.loads(json.dumps(base)) +missing["coverpoints"][0]["name"] = "missing_point" +(root / "missing.json").write_text(json.dumps(missing), encoding="utf-8") + +threshold = json.loads(json.dumps(base)) +threshold["thresholds"]["toggle"] = 100 +(root / "threshold.json").write_text(json.dumps(threshold), encoding="utf-8") + +stale = json.loads(json.dumps(base)) +stale["exclusions"] = [{ + "source": "rtl/dut.sv", + "metric": "toggle", + "reason": "Deliberately stale test waiver.", + "owner": "verification", + "scope": {"line_start": 99}, +}] +(root / "stale.json").write_text(json.dumps(stale), encoding="utf-8") + +malformed = json.loads(json.dumps(base)) +malformed["coverpoints"][0]["minimum_hits"] = 0 +(root / "malformed.json").write_text(json.dumps(malformed), encoding="utf-8") + +formal = json.loads(json.dumps(base)) +formal["formal"]["required"] = True +(root / "formal.json").write_text(json.dumps(formal), encoding="utf-8") + +lcov_only = json.loads(json.dumps(base)) +lcov_only["thresholds"] = {"line": 100, "branch": 100, "toggle": 50} +lcov_only["coverpoints"] = [] +(root / "lcov-only.json").write_text(json.dumps(lcov_only), encoding="utf-8") + +for metric in ("line", "branch", "toggle", "user"): + metric_policy = json.loads(json.dumps(base)) + metric_policy["thresholds"] = {metric: 100} + metric_policy["coverpoints"] = [] + (root / f"{metric}-threshold.json").write_text( + json.dumps(metric_policy), encoding="utf-8" + ) +PY + +qualify() { + local policy="$1" + local formal_result="${2:-SKIP}" + local native="${3:-${fixture_root}/evidence/coverage.dat}" + local lcov="${4:-${fixture_root}/evidence/coverage.info}" + "${flow_root}/ci/coverage_qualification.py" qualify \ + --policy "${fixture_root}/${policy}.json" --module-root "${fixture_root}" \ + --native "${native}" --lcov "${lcov}" --source verilator_sim \ + --formal-result "${formal_result}" --output "${fixture_root}/reports/${policy}.json" +} + +expect_failure() { + local expected="$1" + shift + if "$@" >"${fixture_root}/failure.log" 2>&1; then + echo "Expected coverage qualification failure containing: ${expected}" >&2 + exit 1 + fi + grep -Fq "${expected}" "${fixture_root}/failure.log" +} + +qualify policy +qualify complete +qualify lcov-only SKIP "${fixture_root}/evidence/missing.dat" +expect_failure "required coverpoint missing_point is absent" qualify missing +expect_failure "toggle coverage 50.00% is below threshold 100%" qualify threshold +for metric in line branch toggle user; do + expect_failure "${metric} coverage" qualify "${metric}-threshold" SKIP \ + "${fixture_root}/evidence/deficient.dat" +done +expect_failure "is stale or unmatched" qualify stale +expect_failure "minimum_hits must be a positive integer" qualify malformed +expect_failure "formal cover reachability is FAIL" qualify formal FAIL +expect_failure "formal cover configuration reached no cover statements" qualify formal PASS + +adapter_environment=( + "MODULE_ROOT=${fixture_root}" + "FLOW_ROOT=${flow_root}" + "DESIGN_TOP=dut" + "TB_TOP=dut_tb" + "FORMAL_TOP=dut_formal" + "DUT_INSTANCE=dut_tb/dut" + "RTL_FILELIST=${fixture_root}/rtl.f" + "TB_FILELIST=${fixture_root}/tb.f" + "CONSTRAINT_DIR=${fixture_root}" + "REPORT_DIR=${fixture_root}/adapter-reports" + "WORK_DIR=${fixture_root}/adapter-work" + "COVERAGE_QUALIFICATION_POLICY=${fixture_root}/policy.json" + "COVERAGE_QUALIFICATION_TOOL=${flow_root}/ci/coverage_qualification.py" +) +expect_failure "Coverage source verilator_sim must have PASS status" \ + env "${adapter_environment[@]}" "${flow_root}/flows/coverage/run.sh" +test "$(<"${fixture_root}/adapter-reports/coverage_qualification/status.txt")" = FAIL + +env \ + MOSAIC_FLOW_IDS=coverage_qualification \ + FLOW_coverage_qualification=disabled \ + FLOW_DEPENDENCIES_coverage_qualification= \ + DISABLED_FLOWS=coverage_qualification \ + REPORT_DIR="${fixture_root}/skip-reports" MODULE_ROOT="${fixture_root}" \ + "${flow_root}/ci/run_flow.sh" coverage_qualification false >/dev/null +test "$(<"${fixture_root}/skip-reports/coverage_qualification/status.txt")" = SKIP + +python3 - "${fixture_root}/reports/complete.json" <<'PY' +import json +import pathlib +import sys + +summary = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8")) +assert summary["status"] == "PASS" +assert summary["metrics"]["toggle"] == { + "excluded": 1, + "hit": 1, + "passed": True, + "percent": 100.0, + "threshold": 100, + "total": 1, +} +assert summary["coverpoints"][0]["hits"] == 3 +assert summary["exclusions"][0]["matched_records"] == 1 +assert summary["formal"] == { + "reached_statements": 0, + "required": False, + "status": "SKIP", +} + +failure = json.loads( + pathlib.Path(sys.argv[1]).with_name("threshold.json").read_text(encoding="utf-8") +) +assert failure["status"] == "FAIL" +assert failure["failures"] == [ + "toggle coverage 50.00% is below threshold 100%" +] +PY + +echo "Coverage qualification policy tests passed" diff --git a/tests/test_coverage_unreachable_formal.sh b/tests/test_coverage_unreachable_formal.sh new file mode 100755 index 0000000..4b930f1 --- /dev/null +++ b/tests/test_coverage_unreachable_formal.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -euo pipefail + +flow_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +fixture_root="${flow_root}/tests/fixture-module" +unreachable_config="${fixture_root}/config/formal_cover_unreachable.sby" +summary="${fixture_root}/reports/coverage_qualification/summary.json" +status="${fixture_root}/reports/coverage_qualification/status.txt" + +# Reuse the passing native simulation evidence produced by the fixture job, but +# replace formal cover intent with one deliberately unreachable statement. +if make -C "${fixture_root}" FLOW_ROOT="${flow_root}" \ + FORMAL_COVER_CONFIG="${unreachable_config}" open-coverage \ + >"${fixture_root}/reports/unreachable-formal-test.log" 2>&1; then + echo "Coverage qualification accepted an unreachable formal cover statement" >&2 + exit 1 +fi + +test "$(<"${status}")" = FAIL +python3 - "${summary}" <<'PY' +import json +import pathlib +import sys + +summary = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8")) +assert summary["status"] == "FAIL" +assert summary["formal"]["required"] is True +assert summary["formal"]["status"] == "FAIL" +assert summary["formal"]["reached_statements"] == 0 +assert summary["failures"] == [ + "formal cover reachability is FAIL, expected PASS for all cover statements" +] +PY + +# Leave the fixture in its normal passing state for subsequent release evidence. +make -C "${fixture_root}" FLOW_ROOT="${flow_root}" open-coverage >/dev/null + +echo "Unreachable formal cover correctly failed coverage qualification" diff --git a/tests/test_multi_module.sh b/tests/test_multi_module.sh new file mode 100755 index 0000000..e8882c7 --- /dev/null +++ b/tests/test_multi_module.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env bash +set -euo pipefail + +flow_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +fixture_root="${flow_root}/tests/fixture-multi-module" +temporary_root="$(mktemp -d)" +trap 'rm -rf "${temporary_root}"; make -s -C "${fixture_root}" FLOW_ROOT="${flow_root}" clean' EXIT + +make -s -C "${fixture_root}" FLOW_ROOT="${flow_root}" module-manifest-check >/dev/null + +module_list="$(make -s -C "${fixture_root}" FLOW_ROOT="${flow_root}" module-list)" +if [[ "${module_list}" != $'alpha\nbeta' ]]; then + echo "Module list is not deterministic: ${module_list}" >&2 + exit 1 +fi + +module_matrix="$(make -s -C "${fixture_root}" FLOW_ROOT="${flow_root}" module-matrix)" +python3 -c ' +import json +import sys + +matrix = json.loads(sys.argv[1]) +assert [entry["name"] for entry in matrix["include"]] == ["alpha", "beta"] +assert all(entry["artifact_suffix"] == "native" for entry in matrix["include"]) +' "${module_matrix}" + +if make -s -C "${fixture_root}" FLOW_ROOT="${flow_root}" flow-config-check \ + >"${temporary_root}/missing-selection.log" 2>&1; then + echo "A multi-module flow ran without selecting MODULE" >&2 + exit 1 +fi +grep -Fq "select MODULE=" "${temporary_root}/missing-selection.log" + +if make -s -C "${fixture_root}" FLOW_ROOT="${flow_root}" MODULE=missing \ + fixture-probe >"${temporary_root}/unknown-selection.log" 2>&1; then + echo "A multi-module flow accepted an unknown MODULE" >&2 + exit 1 +fi +grep -Fq "Unknown MODULE 'missing'" "${temporary_root}/unknown-selection.log" + +make -s -C "${fixture_root}" FLOW_ROOT="${flow_root}" MODULE=alpha fixture-probe +make -s -C "${fixture_root}" FLOW_ROOT="${flow_root}" MODULE=beta fixture-probe + +alpha_probe="${fixture_root}/reports/alpha/probe/config.txt" +beta_probe="${fixture_root}/reports/beta/probe/config.txt" +grep -Fq "rtl_filelist=${fixture_root}/filelists/alpha.rtl.f" "${alpha_probe}" +grep -Fq "formal_config=${fixture_root}/flows/symbiyosys/alpha.formal.sby" "${alpha_probe}" +grep -Fq "format_args=--indentation_spaces=4" "${alpha_probe}" +grep -Fq "rtl_filelist=${fixture_root}/filelists/rtl.f" "${beta_probe}" +grep -Fq "formal_config=${fixture_root}/flows/symbiyosys/formal.sby" "${beta_probe}" + +make -s -C "${fixture_root}" FLOW_ROOT="${flow_root}" MODULE=alpha clean +if [[ -e "${fixture_root}/reports/alpha/probe/status.txt" ]] || + [[ -e "${fixture_root}/work/alpha" ]]; then + echo "Selected clean retained alpha-generated state" >&2 + exit 1 +fi +if [[ ! -e "${fixture_root}/reports/beta/probe/status.txt" ]] || + [[ ! -e "${fixture_root}/work/beta/probe/output.txt" ]]; then + echo "Selected clean removed beta-generated state" >&2 + exit 1 +fi + +make -s -C "${fixture_root}" FLOW_ROOT="${flow_root}" clean +make -s -C "${fixture_root}" FLOW_ROOT="${flow_root}" \ + TARGET=fixture-probe MODULE_JOBS=2 all-modules +for module_name in alpha beta; do + test "$(<"${fixture_root}/reports/${module_name}/probe/status.txt")" = PASS +done + +make -s -C "${fixture_root}" FLOW_ROOT="${flow_root}" clean +if make -s -C "${fixture_root}" FLOW_ROOT="${flow_root}" \ + TARGET=fixture-probe MODULE_JOBS=2 FAIL_MODULE=beta all-modules \ + >"${temporary_root}/aggregate-failure.log" 2>&1; then + echo "Aggregate execution accepted a failing module" >&2 + exit 1 +fi +test "$(<"${fixture_root}/reports/alpha/probe/status.txt")" = PASS +test "$(<"${fixture_root}/reports/beta/probe/status.txt")" = FAIL + +if make -s -C "${fixture_root}" FLOW_ROOT="${flow_root}" \ + TARGET=all-modules all-modules >/dev/null 2>&1; then + echo "Aggregate execution accepted recursive TARGET=all-modules" >&2 + exit 1 +fi +if make -s -C "${fixture_root}" FLOW_ROOT="${flow_root}" \ + MODULE_JOBS=-1 all-modules >/dev/null 2>&1; then + echo "Aggregate execution accepted an invalid MODULE_JOBS value" >&2 + exit 1 +fi + +mkdir -p "${temporary_root}/config/modules" +touch "${temporary_root}/config/modules/alpha.mk" +touch "${temporary_root}/config/modules/alpha-flows.mk" + +check_invalid_manifest() { + local manifest_body="$1" + local expected_message="$2" + printf '%s\n' "${manifest_body}" >"${temporary_root}/config/modules.json" + if python3 "${flow_root}/ci/module_manifest.py" validate \ + --manifest "${temporary_root}/config/modules.json" \ + --module-root "${temporary_root}" \ + >"${temporary_root}/invalid.log" 2>&1; then + echo "Invalid module manifest was accepted" >&2 + exit 1 + fi + grep -Fq "${expected_message}" "${temporary_root}/invalid.log" +} + +check_invalid_manifest \ + '{"schema":"mosaic-modules-v1","include":[{"name":"alpha"},{"name":"alpha"}]}' \ + "Duplicate module name" +check_invalid_manifest \ + '{"schema":"mosaic-modules-v1","include":[{"name":"../alpha"}]}' \ + "invalid name" +check_invalid_manifest \ + '{"schema":"wrong","include":[{"name":"alpha"}]}' \ + "schema must be" +check_invalid_manifest \ + '{"schema":"mosaic-modules-v1","include":[{"name":"missing"}]}' \ + "missing design configuration" + +rm -f "${temporary_root}/config/modules/alpha-flows.mk" +check_invalid_manifest \ + '{"schema":"mosaic-modules-v1","include":[{"name":"alpha"}]}' \ + "missing flow policy" + +# The original import sequence remains valid when no module manifest exists. +make -s -C "${flow_root}/tests/fixture-module" FLOW_ROOT="${flow_root}" \ + flow-config-check >/dev/null + +echo "Multi-module project orchestration tests passed" diff --git a/tests/test_openroad_evidence.sh b/tests/test_openroad_evidence.sh new file mode 100755 index 0000000..532198f --- /dev/null +++ b/tests/test_openroad_evidence.sh @@ -0,0 +1,271 @@ +#!/usr/bin/env bash +set -euo pipefail + +flow_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +fixture_root="$(mktemp -d)" +trap 'rm -rf "${fixture_root}"' EXIT + +module_root="${fixture_root}/module" +orfs_root="${fixture_root}/orfs" +mkdir -p \ + "${module_root}/config" \ + "${module_root}/constraints" \ + "${module_root}/filelists" \ + "${module_root}/rtl" \ + "${module_root}/tools" \ + "${orfs_root}/flow" +printf 'module dut(input logic clk_i); endmodule\n' >"${module_root}/rtl/dut.sv" +printf '%s\n' "${module_root}/rtl/dut.sv" >"${module_root}/filelists/rtl.f" +cp "${module_root}/filelists/rtl.f" "${module_root}/filelists/tb.f" +printf 'create_clock -period 10 [get_ports clk_i]\n' >"${module_root}/constraints/timing.sdc" +cat >"${module_root}/config/openroad.mk" <<'EOF' +export DESIGN_NAME = dut +export PLATFORM = $(OPENROAD_PLATFORM) +export VERILOG_FILES = $(REPO_ROOT)/rtl/dut.sv +export SDC_FILE = $(REPO_ROOT)/constraints/timing.sdc +EOF +cat >"${module_root}/config/openroad-evidence.json" <<'EOF' +{ + "schema": "mosaic-openroad-evidence-policy-v1", + "artifacts": [ + {"name": "final_def", "directory": "results", "path": "6_final.def"}, + {"name": "final_gds", "directory": "results", "path": "6_final.gds"}, + {"name": "final_odb", "directory": "results", "path": "6_final.odb"}, + {"name": "final_sdc", "directory": "results", "path": "6_final.sdc"}, + {"name": "final_netlist", "directory": "results", "path": "6_final.v"} + ], + "metrics": [ + {"name": "setup_violations", "directory": "reports", "path": "6_finish.rpt", "pattern": "setup violation count +(?P[0-9]+)", "maximum": 0}, + {"name": "hold_violations", "directory": "reports", "path": "6_finish.rpt", "pattern": "hold violation count +(?P[0-9]+)", "maximum": 0}, + {"name": "slew_violations", "directory": "reports", "path": "6_finish.rpt", "pattern": "slew violation count +(?P[0-9]+)", "maximum": 0}, + {"name": "fanout_violations", "directory": "reports", "path": "6_finish.rpt", "pattern": "fanout violation count +(?P[0-9]+)", "maximum": 0}, + {"name": "capacitance_violations", "directory": "reports", "path": "6_finish.rpt", "pattern": "capacitance violation count +(?P[0-9]+)", "maximum": 0}, + {"name": "routing_violations", "directory": "logs", "path": "5_2_route.log", "pattern": "Number of violations = (?P[0-9]+)", "maximum": 0} + ] +} +EOF + +cat >"${orfs_root}/flow/Makefile" <<'EOF' +.RECIPEPREFIX := > +.PHONY: all +all: +>mkdir -p "$(WORK_HOME)/results/$(PLATFORM)/$(DESIGN_NICKNAME)/$(FLOW_VARIANT)" +>mkdir -p "$(WORK_HOME)/reports/$(PLATFORM)/$(DESIGN_NICKNAME)/$(FLOW_VARIANT)" +>mkdir -p "$(WORK_HOME)/logs/$(PLATFORM)/$(DESIGN_NICKNAME)/$(FLOW_VARIANT)" +>mkdir -p "$(WORK_HOME)/objects/$(PLATFORM)/$(DESIGN_NICKNAME)/$(FLOW_VARIANT)" +>for artifact in 6_final.def 6_final.gds 6_final.odb 6_final.sdc 6_final.v; do printf 'fixture\n' >"$(WORK_HOME)/results/$(PLATFORM)/$(DESIGN_NICKNAME)/$(FLOW_VARIANT)/$${artifact}"; done +>printf 'setup violation count 0\nhold violation count 0\nslew violation count 0\nfanout violation count 0\ncapacitance violation count 0\n' >"$(WORK_HOME)/reports/$(PLATFORM)/$(DESIGN_NICKNAME)/$(FLOW_VARIANT)/6_finish.rpt" +>printf 'Number of violations = 0\n' >"$(WORK_HOME)/logs/$(PLATFORM)/$(DESIGN_NICKNAME)/$(FLOW_VARIANT)/5_2_route.log" +EOF + +common_environment=( + "MODULE_ROOT=${module_root}" + "FLOW_ROOT=${flow_root}" + "DESIGN_TOP=dut" + "TB_TOP=dut_tb" + "FORMAL_TOP=dut_formal" + "DUT_INSTANCE=dut_tb/dut" + "RTL_FILELIST=${module_root}/filelists/rtl.f" + "TB_FILELIST=${module_root}/filelists/tb.f" + "CONSTRAINT_DIR=${module_root}/constraints" + "OPENROAD_CONFIG=${module_root}/config/openroad.mk" + "OPENROAD_CONSTRAINT_FILE=${module_root}/constraints/timing.sdc" + "OPENROAD_EVIDENCE_POLICY=${module_root}/config/openroad-evidence.json" + "OPENROAD_EVIDENCE_TOOL=${flow_root}/ci/openroad_evidence.py" + "OPENROAD_EXECUTION_MODE=local" + "OPENROAD_FLOW_ROOT=${orfs_root}" + "OPENROAD_PLATFORM=nangate45" +) + +run_profile() { + local profile="$1" + env "${common_environment[@]}" \ + OPENROAD_DESIGN_NAME="dut_${profile}" \ + OPENROAD_FLOW_VARIANT="${profile}" \ + REPORT_DIR="${module_root}/reports/${profile}" \ + WORK_DIR="${module_root}/work/${profile}" \ + "${flow_root}/flows/openroad/run.sh" +} + +# Concurrent profile runs must retain independent ORFS and normalized evidence. +run_profile narrow & +narrow_pid=$! +run_profile wide & +wide_pid=$! +wait "${narrow_pid}" +wait "${wide_pid}" +for profile in narrow wide; do + test "$(<"${module_root}/reports/${profile}/openroad/status.txt")" = PASS + grep -Fq '"status": "PASS"' \ + "${module_root}/reports/${profile}/openroad/evidence.json" + test -s "${module_root}/work/${profile}/openroad/results/nangate45/dut_${profile}/${profile}/6_final.gds" +done + +expect_failure() { + local expected="$1" + shift + if "$@" >"${fixture_root}/failure.log" 2>&1; then + echo "Expected OpenROAD failure containing: ${expected}" >&2 + exit 1 + fi + if ! grep -Fq "${expected}" "${fixture_root}/failure.log"; then + echo "OpenROAD failure did not contain: ${expected}" >&2 + cat "${fixture_root}/failure.log" >&2 + exit 1 + fi +} + +wide_root="${module_root}/work/wide/openroad" + +expect_failure "Container image reference digest does not match the recorded digest" \ + "${flow_root}/ci/openroad_evidence.py" \ + --module-root "${module_root}" \ + --policy "${module_root}/config/openroad-evidence.json" \ + --evidence-root "${wide_root}" \ + --design-config "${module_root}/config/openroad.mk" \ + --constraint "${module_root}/constraints/timing.sdc" \ + --output "${module_root}/reports/image-mismatch.json" \ + --execution-mode container --design dut_wide --platform nangate45 --variant wide \ + --image-reference "openroad/orfs@sha256:$(printf '0%.0s' {1..64})" \ + --image-digest "sha256:d995618be9f2bcdfa5538b885123463070dfbf178bea1818716d4652fe0fa380" +grep -Fq '"status": "FAIL"' "${module_root}/reports/image-mismatch.json" + +rm "${wide_root}/results/nangate45/dut_wide/wide/6_final.gds" +expect_failure "Missing required artifact final_gds" \ + "${flow_root}/ci/openroad_evidence.py" \ + --module-root "${module_root}" \ + --policy "${module_root}/config/openroad-evidence.json" \ + --evidence-root "${wide_root}" \ + --design-config "${module_root}/config/openroad.mk" \ + --constraint "${module_root}/constraints/timing.sdc" \ + --output "${module_root}/reports/missing-artifact.json" \ + --execution-mode local --design dut_wide --platform nangate45 --variant wide +grep -Fq '"status": "FAIL"' "${module_root}/reports/missing-artifact.json" + +: >"${wide_root}/results/nangate45/dut_wide/wide/6_final.gds" +expect_failure "Empty required artifact final_gds" \ + "${flow_root}/ci/openroad_evidence.py" \ + --module-root "${module_root}" \ + --policy "${module_root}/config/openroad-evidence.json" \ + --evidence-root "${wide_root}" \ + --design-config "${module_root}/config/openroad.mk" \ + --constraint "${module_root}/constraints/timing.sdc" \ + --output "${module_root}/reports/empty-artifact.json" \ + --execution-mode local --design dut_wide --platform nangate45 --variant wide +grep -Fq '"status": "FAIL"' "${module_root}/reports/empty-artifact.json" + +printf 'fixture\n' >"${wide_root}/results/nangate45/dut_wide/wide/6_final.gds" +printf 'Number of violations = 2\n' \ + >"${wide_root}/logs/nangate45/dut_wide/wide/5_2_route.log" +expect_failure "Metric routing_violations is 2, expected maximum 0" \ + "${flow_root}/ci/openroad_evidence.py" \ + --module-root "${module_root}" \ + --policy "${module_root}/config/openroad-evidence.json" \ + --evidence-root "${wide_root}" \ + --design-config "${module_root}/config/openroad.mk" \ + --constraint "${module_root}/constraints/timing.sdc" \ + --output "${module_root}/reports/routing-violation.json" \ + --execution-mode local --design dut_wide --platform nangate45 --variant wide + +printf 'Number of violations = 0\n' \ + >"${wide_root}/logs/nangate45/dut_wide/wide/5_2_route.log" +sed -i 's/setup violation count 0/setup violation count 3/' \ + "${wide_root}/reports/nangate45/dut_wide/wide/6_finish.rpt" +expect_failure "Metric setup_violations is 3, expected maximum 0" \ + "${flow_root}/ci/openroad_evidence.py" \ + --module-root "${module_root}" \ + --policy "${module_root}/config/openroad-evidence.json" \ + --evidence-root "${wide_root}" \ + --design-config "${module_root}/config/openroad.mk" \ + --constraint "${module_root}/constraints/timing.sdc" \ + --output "${module_root}/reports/setup-violation.json" \ + --execution-mode local --design dut_wide --platform nangate45 --variant wide + +expect_failure "must use an immutable @sha256 digest" env \ + "${common_environment[@]}" \ + OPENROAD_EXECUTION_MODE=container \ + OPENROAD_ORFS_IMAGE=openroad/orfs:latest \ + REPORT_DIR="${module_root}/reports/floating" \ + WORK_DIR="${module_root}/work/floating" \ + OPENROAD_DESIGN_NAME=dut \ + OPENROAD_FLOW_VARIANT=floating \ + "${flow_root}/flows/openroad/run.sh" +test "$(<"${module_root}/reports/floating/openroad/status.txt")" = FAIL + +expect_failure "container runtime is unavailable" env \ + "${common_environment[@]}" \ + OPENROAD_EXECUTION_MODE=container \ + OPENROAD_CONTAINER_RUNTIME=mosaic-missing-runtime \ + OPENROAD_ORFS_IMAGE=openroad/orfs@sha256:d995618be9f2bcdfa5538b885123463070dfbf178bea1818716d4652fe0fa380 \ + REPORT_DIR="${module_root}/reports/runtime" \ + WORK_DIR="${module_root}/work/runtime" \ + OPENROAD_DESIGN_NAME=dut \ + OPENROAD_FLOW_VARIANT=runtime \ + "${flow_root}/flows/openroad/run.sh" +test "$(<"${module_root}/reports/runtime/openroad/status.txt")" = FAIL + +skip_reports="${module_root}/reports/skip" +env \ + MODULE_ROOT="${module_root}" \ + REPORT_DIR="${skip_reports}" \ + MOSAIC_FLOW_IDS=openroad \ + FLOW_openroad=enabled \ + FLOW_DEPENDENCIES_openroad= \ + DISABLED_FLOWS=openroad \ + "${flow_root}/ci/run_flow.sh" openroad false >/dev/null +test "$(<"${skip_reports}/openroad/status.txt")" = SKIP +grep -Fq 'Disabled by DISABLED_FLOWS' \ + "${skip_reports}/openroad/skip_reason.txt" + +cat >"${module_root}/tools/fake-runtime" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail + +case "${1:-}" in + --version) + echo "fake-runtime 1.0" + ;; + image) + [[ "${2:-}" == inspect && -f "${FAKE_RUNTIME_STATE}" ]] + ;; + pull) + printf 'available\n' >"${FAKE_RUNTIME_STATE}" + ;; + save) + [[ "${2:-}" == --output ]] + printf 'cached layers\n' >"${3}" + ;; + load) + [[ "${2:-}" == --input && -s "${3}" ]] + printf 'available\n' >"${FAKE_RUNTIME_STATE}" + ;; + *) + echo "Unexpected fake runtime command: $*" >&2 + exit 2 + ;; +esac +EOF +chmod +x "${module_root}/tools/fake-runtime" +cache_root="${fixture_root}/image-cache" +runtime_state="${fixture_root}/runtime-state" +pinned_image="openroad/orfs@sha256:d995618be9f2bcdfa5538b885123463070dfbf178bea1818716d4652fe0fa380" +env \ + PATH="${module_root}/tools:${PATH}" \ + FAKE_RUNTIME_STATE="${runtime_state}" \ + OPENROAD_CONTAINER_RUNTIME=fake-runtime \ + OPENROAD_ORFS_IMAGE="${pinned_image}" \ + OPENROAD_IMAGE_CACHE_ROOT="${cache_root}" \ + "${flow_root}/ci/cache_openroad_image.sh" >/dev/null +archive="${cache_root}/d995618be9f2bcdfa5538b885123463070dfbf178bea1818716d4652fe0fa380.tar" +test -s "${archive}" +rm "${runtime_state}" +env \ + PATH="${module_root}/tools:${PATH}" \ + FAKE_RUNTIME_STATE="${runtime_state}" \ + OPENROAD_CONTAINER_RUNTIME=fake-runtime \ + OPENROAD_ORFS_IMAGE="${pinned_image}" \ + OPENROAD_IMAGE_CACHE_ROOT="${cache_root}" \ + "${flow_root}/ci/cache_openroad_image.sh" >/dev/null +test -s "${runtime_state}" + +echo "OpenROAD execution and physical evidence policy tests passed" diff --git a/tests/test_parameter_profiles.sh b/tests/test_parameter_profiles.sh new file mode 100755 index 0000000..528e784 --- /dev/null +++ b/tests/test_parameter_profiles.sh @@ -0,0 +1,250 @@ +#!/usr/bin/env bash +set -euo pipefail + +flow_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +fixture_root="${flow_root}/tests/fixture-parameter-profiles" +temporary_root="$(mktemp -d)" +trap 'find "${temporary_root}" -depth -delete; make -s -C "${fixture_root}" FLOW_ROOT="${flow_root}" clean' EXIT + +make -s -C "${fixture_root}" FLOW_ROOT="${flow_root}" profile-manifest-check >/dev/null +profile_list="$(make -s -C "${fixture_root}" FLOW_ROOT="${flow_root}" profile-list)" +if [[ "${profile_list}" != $'width_min\nnominal\nfeature_invert\nelaboration_only' ]]; then + echo "Profile list is not deterministic: ${profile_list}" >&2 + exit 1 +fi + +profile_matrix="$(make -s -C "${fixture_root}" FLOW_ROOT="${flow_root}" profile-matrix)" +python3 -c ' +import json +import sys + +matrix = json.loads(sys.argv[1]) +assert [entry["profile"] for entry in matrix["include"]] == [ + "width_min", "nominal", "feature_invert", "elaboration_only" +] +assert len({entry["job_name"] for entry in matrix["include"]}) == 4 +assert all(entry["module"] == "profile_fixture" for entry in matrix["include"]) +' "${profile_matrix}" + +if make -s -C "${fixture_root}" FLOW_ROOT="${flow_root}" flow-config-check \ + >"${temporary_root}/missing-selection.log" 2>&1; then + echo "A parameterized flow ran without selecting PROFILE" >&2 + exit 1 +fi +grep -Fq "select PROFILE=" "${temporary_root}/missing-selection.log" + +if make -s -C "${fixture_root}" FLOW_ROOT="${flow_root}" PROFILE=nominal \ + FLOW_yosys_synthesis=disabled flow-config-check \ + >"${temporary_root}/policy-conflict.log" 2>&1; then + echo "A profile required a flow disabled by module policy" >&2 + exit 1 +fi +grep -Fq "requires flows disabled by module policy: yosys_synthesis" \ + "${temporary_root}/policy-conflict.log" + +for profile in width_min nominal feature_invert elaboration_only; do + make -s -C "${fixture_root}" FLOW_ROOT="${flow_root}" \ + PROFILE="${profile}" profile-probe + test -f "${fixture_root}/reports/${profile}/parameter-profile.json" + test -f "${fixture_root}/reports/${profile}/probe/status.txt" +done + +python3 -c ' +import json +import pathlib + +root = pathlib.Path("tests/fixture-parameter-profiles/reports") +assert json.loads((root / "width_min/parameter-profile.json").read_text())["parameters"] == { + "FEATURE_INVERT": False, + "WIDTH": 1, +} +evidence = json.loads((root / "feature_invert/parameter-profile.json").read_text()) +assert evidence["parameters"] == {"FEATURE_INVERT": True, "WIDTH": 5} +assert evidence["tops"]["formal"] == "profile_fixture_formal" +' + +make -s -C "${fixture_root}" FLOW_ROOT="${flow_root}" PROFILE=width_min clean +test ! -e "${fixture_root}/reports/width_min/probe/status.txt" +test -e "${fixture_root}/reports/nominal/probe/status.txt" + +make -s -C "${fixture_root}" FLOW_ROOT="${flow_root}" clean +make -s -C "${fixture_root}" FLOW_ROOT="${flow_root}" \ + PROFILE_TARGET=profile-probe PROFILE_JOBS=2 all-profiles +for profile in width_min nominal feature_invert elaboration_only; do + test "$(<"${fixture_root}/reports/${profile}/probe/status.txt")" = PASS +done +python3 -c ' +import json +import pathlib + +summary = json.loads( + pathlib.Path("tests/fixture-parameter-profiles/reports/parameter-profile-summary.json").read_text() +) +assert summary["aggregate_status"] == "PASS" +assert all(item["statuses"]["probe"] == "PASS" for item in summary["profiles"]) +' + +make -s -C "${fixture_root}" FLOW_ROOT="${flow_root}" clean +if make -s -C "${fixture_root}" FLOW_ROOT="${flow_root}" \ + PROFILE_TARGET=profile-probe PROFILE_JOBS=2 FAIL_PROFILE=nominal all-profiles \ + >"${temporary_root}/aggregate-failure.log" 2>&1; then + echo "Profile aggregate accepted a failing required profile" >&2 + exit 1 +fi +test "$(<"${fixture_root}/reports/width_min/probe/status.txt")" = PASS +test "$(<"${fixture_root}/reports/nominal/probe/status.txt")" = FAIL +test "$(<"${fixture_root}/reports/feature_invert/probe/status.txt")" = PASS +test "$(<"${fixture_root}/reports/elaboration_only/probe/status.txt")" = PASS +python3 -c ' +import json +import pathlib + +summary = json.loads( + pathlib.Path("tests/fixture-parameter-profiles/reports/parameter-profile-summary.json").read_text() +) +assert summary["aggregate_status"] == "FAIL" +assert summary["profiles"][1]["statuses"]["probe"] == "FAIL" +' + +if make -s -C "${fixture_root}" FLOW_ROOT="${flow_root}" \ + PROFILE_TARGET=all-profiles all-profiles >/dev/null 2>&1; then + echo "Profile aggregate accepted recursive PROFILE_TARGET=all-profiles" >&2 + exit 1 +fi +if make -s -C "${fixture_root}" FLOW_ROOT="${flow_root}" \ + PROFILE_JOBS=-1 all-profiles >/dev/null 2>&1; then + echo "Profile aggregate accepted an invalid PROFILE_JOBS value" >&2 + exit 1 +fi + +profile_tool="${flow_root}/ci/parameter_profiles.py" +argument_output="$( + python3 "${profile_tool}" arguments \ + --parameters '{"FEATURE_INVERT":true,"WIDTH":5}' \ + --backend verilator --top profile_fixture +)" +if [[ "${argument_output}" != $'-GFEATURE_INVERT=1\n-GWIDTH=5' ]]; then + echo "Unexpected Verilator parameter translation: ${argument_output}" >&2 + exit 1 +fi + +test "$(${profile_tool} arguments --parameters '{"WIDTH":5}' --backend slang --top ignored)" = $'-G\nWIDTH=5' +test "$(${profile_tool} arguments --parameters '{"WIDTH":5}' --backend vcs --top profile_fixture_tb)" = '-pvalue+profile_fixture_tb.WIDTH=5' +test "$(${profile_tool} arguments --parameters '{"FEATURE_INVERT":true,"WIDTH":5}' --backend dc --top ignored)" = 'FEATURE_INVERT=1,WIDTH=5' +test "$(${profile_tool} arguments --parameters '{"WIDTH":5}' --backend yosys --top profile_fixture)" = 'chparam -set WIDTH 5 profile_fixture;' + +cat >"${temporary_root}/ambiguous.eqy" <<'EOF' +[gold] +prep -top profile_fixture +[gate] +read_verilog first.v second.v +prep -top profile_fixture +EOF +if "${profile_tool}" render-eqy \ + --input "${temporary_root}/ambiguous.eqy" \ + --output "${temporary_root}/rendered.eqy" \ + --parameters '{"WIDTH":5}' --top profile_fixture \ + --netlist "${temporary_root}/profile_fixture_netlist.v" \ + >"${temporary_root}/ambiguous.log" 2>&1; then + echo "An ambiguous EQY gate input set was accepted" >&2 + exit 1 +fi +grep -Fq "exactly one gate HDL input" "${temporary_root}/ambiguous.log" + +mkdir -p "${temporary_root}/blocked/width_min/yosys_synthesis" +printf 'BLOCKED\n' >"${temporary_root}/blocked/width_min/yosys_synthesis/status.txt" +if python3 "${profile_tool}" summary \ + --manifest "${fixture_root}/config/parameter-profiles.json" \ + --report-root "${temporary_root}/blocked" \ + --output "${temporary_root}/blocked-summary.json"; then + echo "A blocked profile produced a successful summary exit status" >&2 + exit 1 +fi +python3 -c ' +import json +import pathlib + +summary = json.loads(pathlib.Path("'"${temporary_root}"'/blocked-summary.json").read_text()) +assert summary["aggregate_status"] == "FAIL" +assert summary["profiles"][0]["statuses"]["yosys_synthesis"] == "BLOCKED" +assert summary["profiles"][1]["statuses"]["aggregate"] == "MISSING" +' + +check_invalid_manifest() { + local manifest_body="$1" + local expected_message="$2" + printf '%s\n' "${manifest_body}" >"${temporary_root}/profiles.json" + if python3 "${profile_tool}" validate --manifest "${temporary_root}/profiles.json" \ + >"${temporary_root}/invalid.log" 2>&1; then + echo "Invalid parameter-profile manifest was accepted" >&2 + exit 1 + fi + grep -Fq "${expected_message}" "${temporary_root}/invalid.log" +} + +check_invalid_manifest \ + '{"schema":"mosaic-parameter-profiles-v1","include":[{"name":"a","parameters":{},"flows":["slang_elaboration"]},{"name":"a","parameters":{},"flows":["slang_elaboration"]}]}' \ + "Duplicate profile name" +check_invalid_manifest \ + '{"schema":"mosaic-parameter-profiles-v1","include":[{"name":"a","parameters":{"WIDTH":"1;delete"},"flows":["slang_elaboration"]}]}' \ + "unsupported parameter value" +check_invalid_manifest \ + '{"schema":"mosaic-parameter-profiles-v1","include":[{"name":"a","parameters":{"1WIDTH":1},"flows":["slang_elaboration"]}]}' \ + "invalid parameter name" +check_invalid_manifest \ + '{"schema":"mosaic-parameter-profiles-v1","include":[{"name":"a","parameters":{},"flows":["openroad"]}]}' \ + "unsupported flows" +check_invalid_manifest \ + '{"schema":"mosaic-parameter-profiles-v1","include":[{"name":"a","parameters":{},"flows":["eqy_equivalence"]}]}' \ + "requires: yosys_synthesis" + +printf '%s\n' \ + '{"schema":"mosaic-parameter-profiles-v1","include":[{"name":"qualification","parameters":{"WIDTH":8},"flows":["negative_qualification","four_state_qualification"]}]}' \ + >"${temporary_root}/qualification-profiles.json" +python3 "${profile_tool}" validate \ + --manifest "${temporary_root}/qualification-profiles.json" >/dev/null + +default_profiles="$( + make -s -C "${flow_root}/tests/fixture-module" FLOW_ROOT="${flow_root}" profile-list +)" +test "${default_profiles}" = default +make -s -C "${flow_root}/tests/fixture-module" FLOW_ROOT="${flow_root}" \ + flow-config-check >/dev/null + +combined_matrix="$( + make -s -C "${flow_root}/tests/fixture-multi-module" FLOW_ROOT="${flow_root}" \ + module-profile-matrix +)" +python3 -c ' +import json +import sys + +entries = json.loads(sys.argv[1])["include"] +assert [(entry["module"], entry["profile"]) for entry in entries] == [ + ("alpha", "default"), ("beta", "default") +] +assert len({entry["job_name"] for entry in entries}) == 2 +' "${combined_matrix}" + +profiled_multi_root="${temporary_root}/profiled-multi" +cp -R "${flow_root}/tests/fixture-multi-module" "${profiled_multi_root}" +mkdir -p "${profiled_multi_root}/config/parameter-profiles" +printf '%s\n' \ + '{"schema":"mosaic-parameter-profiles-v1","include":[{"name":"width_min","parameters":{"WIDTH":1},"flows":["slang_elaboration"]},{"name":"nominal","parameters":{"WIDTH":8},"flows":["slang_elaboration"]}]}' \ + >"${profiled_multi_root}/config/parameter-profiles/alpha.json" +combined_matrix="$( + make -s -C "${profiled_multi_root}" FLOW_ROOT="${flow_root}" \ + module-profile-matrix +)" +python3 -c ' +import json +import sys + +entries = json.loads(sys.argv[1])["include"] +assert [(entry["module"], entry["profile"]) for entry in entries] == [ + ("alpha", "width_min"), ("alpha", "nominal"), ("beta", "default") +] +assert len({entry["job_name"] for entry in entries}) == 3 +' "${combined_matrix}" + +echo "Parameter-profile qualification tests passed" diff --git a/tests/test_qualification_campaigns.sh b/tests/test_qualification_campaigns.sh new file mode 100755 index 0000000..6ecb3e5 --- /dev/null +++ b/tests/test_qualification_campaigns.sh @@ -0,0 +1,257 @@ +#!/usr/bin/env bash +set -euo pipefail + +flow_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +fixture_root="$(mktemp -d)" +trap 'rm -rf "${fixture_root}"' EXIT +mkdir -p "${fixture_root}/config" + +write_manifest() { + local negative_exit="$1" + local negative_diagnostic="$2" + local four_state_exit="$3" + local manifest="${fixture_root}/config/qualification-campaigns.json" + python3 - "${manifest}" "${negative_exit}" "${negative_diagnostic}" \ + "${four_state_exit}" <<'PY' +import json +import pathlib +import sys + +path = pathlib.Path(sys.argv[1]) +negative_exit = int(sys.argv[2]) +negative_diagnostic = sys.argv[3] +four_state_exit = int(sys.argv[4]) +driver = "{flow_root}/tests/fixtures/qualification_driver.py" + +def phase(expected, diagnostic, exit_code, failure_class=None, name="run"): + value = { + "name": name, + "command": [ + "{python}", driver, "--diagnostic", diagnostic, + "--exit-code", str(exit_code), + ], + "expected": expected, + "diagnostic": diagnostic, + } + if failure_class: + value["failure_class"] = failure_class + return value + +manifest = { + "schema": "mosaic-qualification-campaigns-v1", + "campaigns": { + "negative": { + "infrastructure_diagnostics": ["UNRELATED_TOOL_FAILURE"], + "cases": [ + { + "id": "positive_control", "evidence": "positive_control", + "role": "positive_control", "kind": "positive_control", + "phases": [phase("success", "POSITIVE_CONTROL_PASS", 0)], + }, + { + "id": "detected_mutation", "evidence": "detected_mutation", + "role": "negative", "kind": "simulation_mutation", + "positive_control": "positive_control", + "phases": [phase( + "failure", negative_diagnostic, negative_exit, "assertion" + )], + }, + { + "id": "expected_eqy_mismatch", "evidence": "expected_eqy_mismatch", + "role": "negative", "kind": "equivalence_mismatch", + "positive_control": "positive_control", + "phases": [phase( + "failure", "Successfully proved designs inequivalent", 1, + "equivalence" + )], + }, + ], + }, + "four_state": { + "simulator": "iverilog", + "cases": [ + { + "id": "disabled_monitor", "evidence": "disabled_monitor", + "role": "disabled_monitor_control", "kind": "stimulus_control", + "injections": [{"control": "enable_i", "values": ["X", "Z"]}], + "phases": [ + { + "name": "compile", + "command": [ + "{iverilog}", "--diagnostic", "COMPILE_PASS", + "--exit-code", "0" + ], + "expected": "success" + }, + { + "name": "run", + "command": [ + "{vvp}", "--diagnostic", "UNKNOWN_STIMULUS_REACHED", + "--exit-code", "0" + ], + "expected": "success", + "diagnostic": "UNKNOWN_STIMULUS_REACHED" + } + ], + }, + { + "id": "unknown_control", "evidence": "unknown_control", + "role": "unknown_detection", "kind": "control_unknown", + "monitor_control": "disabled_monitor", + "injections": [{"control": "enable_i", "values": ["X", "Z"]}], + "phases": [ + { + "name": "compile", + "command": [ + "{iverilog}", "--diagnostic", "COMPILE_PASS", + "--exit-code", "0" + ], + "expected": "success" + }, + { + "name": "run", + "command": [ + "{vvp}", "--diagnostic", "UNKNOWN_CONTROL_DETECTED", + "--exit-code", str(four_state_exit) + ], + "expected": "failure", + "diagnostic": "UNKNOWN_CONTROL_DETECTED", + "failure_class": "unknown_control" + } + ], + }, + ], + }, + }, +} +path.write_text(json.dumps(manifest), encoding="utf-8") +PY + + fake_tools="${fixture_root}/fake-tools" + mkdir -p "${fake_tools}" + cp "${flow_root}/tests/fixtures/qualification_driver.py" "${fake_tools}/iverilog" + cp "${flow_root}/tests/fixtures/qualification_driver.py" "${fake_tools}/vvp" + chmod +x "${fake_tools}/iverilog" "${fake_tools}/vvp" + export IVERILOG_CMD="${fake_tools}/iverilog" + export VVP_CMD="${fake_tools}/vvp" +} + +run_campaign() { + local campaign="$1" + local name="$2" + "${flow_root}/ci/qualification_campaign.py" \ + --campaign "${campaign}" \ + --manifest "${fixture_root}/config/qualification-campaigns.json" \ + --module-root "${fixture_root}" --flow-root "${flow_root}" \ + --report-dir "${fixture_root}/reports/${name}" \ + --work-dir "${fixture_root}/work/${name}" +} + +expect_failure() { + if "$@" >"${fixture_root}/failure.log" 2>&1; then + echo "Qualification fixture unexpectedly passed" >&2 + exit 1 + fi +} + +write_manifest 1 ASSERTION_FAILURE_DETECTED 1 +run_campaign negative negative-pass >/dev/null +run_campaign four_state four-state-pass >/dev/null +grep -Fq '"classification": "expected_assertion_failure"' \ + "${fixture_root}/reports/negative-pass/detected_mutation/summary.json" +grep -Fq '"classification": "expected_equivalence_failure"' \ + "${fixture_root}/reports/negative-pass/expected_eqy_mismatch/summary.json" +python3 - "${fixture_root}" <<'PY' +import json +import pathlib +import sys + +root = pathlib.Path(sys.argv[1]) +negative = json.loads( + (root / "reports/negative-pass/summary.json").read_text(encoding="utf-8") +) +four_state = json.loads( + (root / "reports/four-state-pass/summary.json").read_text(encoding="utf-8") +) +assert negative["status"] == "PASS" +assert four_state["status"] == "PASS" +assert four_state["simulator"] == "iverilog" +assert all(case["status"] == "PASS" for case in negative["cases"]) +assert all(case["status"] == "PASS" for case in four_state["cases"]) +for report in ( + root / "reports/negative-pass/detected_mutation", + root / "reports/four-state-pass/iverilog/unknown_control", +): + assert (report / "status.txt").read_text(encoding="utf-8").strip() == "PASS" + assert (report / "summary.json").is_file() + assert (report / "run-command.json").is_file() + assert (report / "run-result.json").is_file() + assert (report / "run.log").is_file() +PY + +write_manifest 0 ASSERTION_FAILURE_DETECTED 1 +expect_failure run_campaign negative escaped-mutation +grep -Fq '"classification": "escaped_fault"' \ + "${fixture_root}/reports/escaped-mutation/detected_mutation/summary.json" +test "$(<"${fixture_root}/reports/escaped-mutation/status.txt")" = FAIL + +write_manifest 2 UNRELATED_TOOL_FAILURE 1 +expect_failure run_campaign negative infrastructure-failure +grep -Fq '"classification": "infrastructure_failure"' \ + "${fixture_root}/reports/infrastructure-failure/detected_mutation/summary.json" + +write_manifest 1 ASSERTION_FAILURE_DETECTED 0 +expect_failure run_campaign four_state broken-monitor +grep -Fq '"classification": "escaped_fault"' \ + "${fixture_root}/reports/broken-monitor/iverilog/unknown_control/summary.json" + +write_manifest 1 ASSERTION_FAILURE_DETECTED 1 +python3 - "${fixture_root}/config/qualification-campaigns.json" <<'PY' +import json +import pathlib +import sys + +path = pathlib.Path(sys.argv[1]) +manifest = json.loads(path.read_text(encoding="utf-8")) +control = manifest["campaigns"]["four_state"]["cases"][0] +control["phases"][0]["diagnostic"] = "BROKEN_CONTROL_MARKER" +path.write_text(json.dumps(manifest), encoding="utf-8") +PY +expect_failure run_campaign four_state broken-control +grep -Fq '"classification": "blocked_by_control"' \ + "${fixture_root}/reports/broken-control/iverilog/unknown_control/summary.json" + +write_manifest 1 ASSERTION_FAILURE_DETECTED 1 +python3 - "${fixture_root}/config/qualification-campaigns.json" <<'PY' +import json +import pathlib +import sys + +path = pathlib.Path(sys.argv[1]) +manifest = json.loads(path.read_text(encoding="utf-8")) +manifest["campaigns"]["negative"]["inputs"] = ["missing-mutation.sv"] +path.write_text(json.dumps(manifest), encoding="utf-8") +PY +expect_failure run_campaign negative missing-input +grep -Fq 'campaign input is missing: missing-mutation.sv' \ + "${fixture_root}/reports/missing-input/summary.json" + +skip_reports="${fixture_root}/skip-reports" +env \ + MOSAIC_FLOW_IDS="negative_qualification four_state_qualification" \ + FLOW_negative_qualification=disabled FLOW_four_state_qualification=disabled \ + FLOW_DEPENDENCIES_negative_qualification= FLOW_DEPENDENCIES_four_state_qualification= \ + DISABLED_FLOWS="negative_qualification four_state_qualification" \ + REPORT_DIR="${skip_reports}" MODULE_ROOT="${fixture_root}" \ + "${flow_root}/ci/run_flow.sh" negative_qualification false >/dev/null +test "$(<"${skip_reports}/negative_qualification/status.txt")" = SKIP +env \ + MOSAIC_FLOW_IDS="negative_qualification four_state_qualification" \ + FLOW_negative_qualification=disabled FLOW_four_state_qualification=disabled \ + FLOW_DEPENDENCIES_negative_qualification= FLOW_DEPENDENCIES_four_state_qualification= \ + DISABLED_FLOWS="negative_qualification four_state_qualification" \ + REPORT_DIR="${skip_reports}" MODULE_ROOT="${fixture_root}" \ + "${flow_root}/ci/run_flow.sh" four_state_qualification false >/dev/null +test "$(<"${skip_reports}/four_state_qualification/status.txt")" = SKIP + +echo "Qualification campaign tests passed" diff --git a/tests/test_quality_gate.sh b/tests/test_quality_gate.sh index f77b83a..7decaad 100755 --- a/tests/test_quality_gate.sh +++ b/tests/test_quality_gate.sh @@ -101,7 +101,7 @@ if [[ -e "${report_root}/eqy_equivalence/block_reason.txt" ]]; then exit 1 fi -required_flows=(verible_lint verible_format slang_elaboration verilator_lint yosys_synthesis symbiyosys_formal eqy_equivalence verilator_sim pyuvm_open_source) +required_flows=(verible_lint verible_format slang_elaboration verilator_lint yosys_synthesis symbiyosys_formal eqy_equivalence verilator_sim pyuvm_open_source coverage_qualification negative_qualification four_state_qualification static_intent) for flow_name in "${required_flows[@]}"; do mkdir -p "${report_root}/${flow_name}" printf 'PASS\n' > "${report_root}/${flow_name}/status.txt" diff --git a/tests/test_release_manifest.sh b/tests/test_release_manifest.sh new file mode 100755 index 0000000..6684d65 --- /dev/null +++ b/tests/test_release_manifest.sh @@ -0,0 +1,419 @@ +#!/usr/bin/env bash +set -euo pipefail + +flow_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +fixture_context="${FIXTURE_CONTEXT:-native}" +fixture_root="$(mktemp -d)" +module_root="${fixture_root}/module" +methodology_root="${fixture_root}/methodology" +packaged_methodology_root="${fixture_root}/packaged-methodology" +report_dir="${module_root}/reports" +output_dir="${report_dir}/release_manifest/${fixture_context}" +release_tool="${flow_root}/ci/release_manifest.py" + +trap 'rm -rf "${fixture_root}"' EXIT + +mkdir -p \ + "${module_root}/config" \ + "${module_root}/constraints" \ + "${module_root}/filelists" \ + "${module_root}/reports/verible_lint" \ + "${module_root}/reports/pyuvm_open_source" \ + "${module_root}/reports/negative_qualification" \ + "${module_root}/reports/qualification" \ + "${module_root}/rtl" \ + "${module_root}/tools" \ + "${module_root}/verif/pyuvm" \ + "${methodology_root}" \ + "${packaged_methodology_root}" + +cp "${flow_root}/VERSION" "${methodology_root}/VERSION" +cp "${flow_root}/VERSION" "${packaged_methodology_root}/VERSION" + +cat >"${module_root}/.gitignore" <<'EOF' +reports/ +EOF +cat >"${module_root}/config/design.mk" <<'EOF' +DESIGN_TOP := release_fixture +EOF +cat >"${module_root}/config/flows.mk" <<'EOF' +FLOW_verible_lint := enabled +FLOW_pyuvm_open_source := enabled +FLOW_negative_qualification := enabled +FLOW_four_state_qualification := disabled +FLOW_openroad := disabled +EOF +cat >"${module_root}/config/qualification-campaigns.json" <<'EOF' +{ + "schema": "mosaic-qualification-campaigns-v1", + "campaigns": { + "negative": { + "inputs": ["config/qualification-negative.txt"], + "cases": [ + { + "id": "negative_control", "evidence": "negative_control", + "role": "positive_control", "kind": "positive_control", + "phases": [{"name": "run", "command": ["true"], "expected": "success"}] + }, + { + "id": "negative_case", "evidence": "negative_case", + "role": "negative", "kind": "simulation_mutation", + "positive_control": "negative_control", + "phases": [{ + "name": "run", "command": ["false"], "expected": "failure", + "diagnostic": "EXPECTED_FAILURE", "failure_class": "mutation" + }] + } + ] + }, + "four_state": { + "simulator": "iverilog", + "inputs": ["config/qualification-four-state.txt"], + "cases": [ + { + "id": "monitor_control", "evidence": "monitor_control", + "role": "disabled_monitor_control", "kind": "stimulus_control", + "injections": [{"control": "enable_i", "values": ["X", "Z"]}], + "phases": [ + {"name": "compile", "command": ["{iverilog}", "fixture.sv"], "expected": "success"}, + {"name": "run", "command": ["{vvp}", "fixture.vvp"], "expected": "success", "diagnostic": "STIMULUS_REACHED"} + ] + }, + { + "id": "unknown_detection", "evidence": "unknown_detection", + "role": "unknown_detection", "kind": "control_unknown", + "monitor_control": "monitor_control", + "injections": [{"control": "enable_i", "values": ["X", "Z"]}], + "phases": [ + {"name": "compile", "command": ["{iverilog}", "fixture.sv"], "expected": "success"}, + {"name": "run", "command": ["{vvp}", "fixture.vvp"], "expected": "failure", "diagnostic": "UNKNOWN_CONTROL", "failure_class": "unknown_control"} + ] + } + ] + } + } +} +EOF +printf 'negative campaign input\n' >"${module_root}/config/qualification-negative.txt" +printf 'four-state campaign input\n' >"${module_root}/config/qualification-four-state.txt" +cat >"${module_root}/constraints/timing.sdc" <<'EOF' +create_clock -name clk -period 10 [get_ports clk] +EOF +cat >"${module_root}/rtl/release_fixture.sv" <<'EOF' +module release_fixture(input logic clk); +endmodule +EOF +cat >"${module_root}/verif/pyuvm/test_release_fixture.py" <<'EOF' +"""Release manifest fixture test module.""" +EOF +cat >"${module_root}/filelists/rtl.f" <<'EOF' ++incdir+rtl +rtl/release_fixture.sv +EOF +cat >"${module_root}/filelists/pyuvm.f" <<'EOF' +-f filelists/rtl.f +EOF +cat >"${module_root}/tools/fake_tool.sh" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +printf 'fixture-tool 1.2.3\n' +EOF +chmod +x "${module_root}/tools/fake_tool.sh" + +printf 'PASS\n' >"${report_dir}/verible_lint/status.txt" +printf 'PASS\n' >"${report_dir}/pyuvm_open_source/status.txt" +printf 'PASS\n' >"${report_dir}/negative_qualification/status.txt" +printf 'PASS\n' >"${report_dir}/qualification/status.txt" +cat >"${report_dir}/pyuvm_open_source/versions.log" <"${report_dir}/pyuvm_open_source/coverage.info" +printf '{"covered":1,"total":1}\n' \ + >"${report_dir}/pyuvm_open_source/functional-coverage.json" +printf '{"review":"complete"}\n' >"${report_dir}/qualification/summary.json" +printf '{"campaign":"negative","status":"PASS"}\n' \ + >"${report_dir}/negative_qualification/summary.json" + +git -C "${module_root}" init --quiet +git -C "${module_root}" config user.email fixture@example.invalid +git -C "${module_root}" config user.name "MOSAIC fixture" +git -C "${module_root}" add . +git -C "${module_root}" commit --quiet -m fixture +git -C "${methodology_root}" init --quiet +git -C "${methodology_root}" config user.email fixture@example.invalid +git -C "${methodology_root}" config user.name "MOSAIC fixture" +git -C "${methodology_root}" add VERSION +git -C "${methodology_root}" commit --quiet -m fixture +module_revision="$(git -C "${module_root}" rev-parse HEAD)" +methodology_revision="$(git -C "${methodology_root}" rev-parse HEAD)" + +manifest_environment=( + "MODULE_ROOT=${module_root}" + "FLOW_ROOT=${methodology_root}" + "REPORT_DIR=${report_dir}" + "MOSAIC_FLOW_IDS=verible_lint pyuvm_open_source negative_qualification four_state_qualification openroad" + "DISABLED_FLOWS=four_state_qualification openroad" + "RELEASE_MODULE_NAME=release_fixture" + "MODULE_REVISION=${module_revision}" + "METHODOLOGY_REVISION=${methodology_revision}" + "RELEASE_EXECUTION_CONTEXT=${fixture_context}" + "RELEASE_ALLOW_DIRTY=disabled" + "RELEASE_TECHNOLOGY=fixture-technology" + 'RELEASE_TECHNOLOGY_METADATA_JSON={"corner":"typical"}' + 'RELEASE_METADATA_JSON={"qualification":"fixture"}' + "RELEASE_EXECUTION_METADATA_JSON={\"fixture_context\":\"${fixture_context}\"}" + "RELEASE_ADDITIONAL_TOOLS_JSON=[{\"name\":\"custom_analyzer\",\"command\":[\"${module_root}/tools/fake_tool.sh\",\"--version\"],\"flows\":[]}]" + "RELEASE_SUPPLEMENTAL_GATES=qualification" + "RELEASE_INPUT_FILES=${module_root}/config/design.mk ${module_root}/config/flows.mk ${module_root}/constraints" + "RELEASE_FILELISTS=${module_root}/filelists/rtl.f ${module_root}/filelists/pyuvm.f" + "RELEASE_ADDITIONAL_EVIDENCE=${report_dir}/qualification/summary.json" + "VERIBLE_LINT_CMD=${module_root}/tools/fake_tool.sh" + "PYUVM_PYTHON=${module_root}/tools/fake_tool.sh" + "IVERILOG_CMD=${module_root}/tools/fake_tool.sh" + "VVP_CMD=${module_root}/tools/fake_tool.sh" + "QUALIFICATION_CAMPAIGN_MANIFEST=${module_root}/config/qualification-campaigns.json" +) + +generate_manifest() { + local destination="$1" + shift + env "${manifest_environment[@]}" "$@" \ + python3 "${release_tool}" generate \ + --module-root "${module_root}" \ + --flow-root "${methodology_root}" \ + --report-dir "${report_dir}" \ + --output-dir "${destination}" +} + +generate_packaged_manifest() { + local destination="$1" + shift + env "${manifest_environment[@]}" \ + "FLOW_ROOT=${packaged_methodology_root}" \ + "$@" \ + python3 "${release_tool}" generate \ + --module-root "${module_root}" \ + --flow-root "${packaged_methodology_root}" \ + --report-dir "${report_dir}" \ + --output-dir "${destination}" +} + +expect_failure() { + local expected_message="$1" + shift + if "$@" >"${fixture_root}/negative.log" 2>&1; then + echo "Release fixture unexpectedly accepted invalid evidence" >&2 + exit 1 + fi + grep -Fq "${expected_message}" "${fixture_root}/negative.log" +} + +generate_manifest "${output_dir}" >/dev/null +python3 "${release_tool}" validate --manifest "${output_dir}/manifest.json" >/dev/null +test "$(<"${output_dir}/status.txt")" = PASS +grep -Fq "MOSAIC release evidence" "${output_dir}/summary.txt" + +python3 - "${output_dir}/manifest.json" "${fixture_context}" <<'PY' +import json +import pathlib +import re +import sys + +manifest = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8")) +context = sys.argv[2] +deterministic = manifest["deterministic"] + +assert manifest["schema"] == "mosaic-release-evidence-v1" +assert deterministic["identity"]["execution_context"] == context +assert re.fullmatch(r"[0-9a-f]{40}", deterministic["identity"]["module_revision"]) +assert deterministic["technology"] == { + "details": {"corner": "typical"}, + "name": "fixture-technology", +} +assert {flow["id"]: flow["status"] for flow in deterministic["flows"]} == { + "four_state_qualification": "SKIP", + "negative_qualification": "PASS", + "openroad": "SKIP", + "pyuvm_open_source": "PASS", + "verible_lint": "PASS", +} +assert deterministic["supplemental_gates"][0]["id"] == "qualification" +assert {tool["name"] for tool in deterministic["tools"]} >= { + "cocotb", "custom_analyzer", "python", "pyuvm", + "verible-verilog-lint", "verilator" +} +custom_tool = next(tool for tool in deterministic["tools"] + if tool["name"] == "custom_analyzer") +assert custom_tool["command"] == ["fake_tool.sh", "--version"] +assert all(re.fullmatch(r"[0-9a-f]{64}", item["sha256"]) + for item in deterministic["inputs"]) +assert all(not pathlib.Path(item["path"]).is_absolute() + for item in deterministic["inputs"]) +input_paths = {item["path"] for item in deterministic["inputs"]} +assert {"config/design.mk", "config/flows.mk", "constraints/timing.sdc", + "config/qualification-campaigns.json", + "config/qualification-negative.txt", "filelists/rtl.f", + "rtl/release_fixture.sv"} <= input_paths +assert "config/qualification-four-state.txt" not in input_paths +coverage_kinds = {item["kind"] for item in deterministic["evidence"]["coverage"]} +assert coverage_kinds == {"pyuvm_functional", "systemverilog_native_report"} +additional_paths = { + item["path"] for item in deterministic["evidence"]["additional"] +} +assert additional_paths == { + "reports/negative_qualification/summary.json", + "reports/qualification/summary.json", +} +assert manifest["volatile"]["source_tree"] == { + "methodology_dirty": False, + "module_dirty": False, +} +PY + +cp "${output_dir}/manifest.json" "${fixture_root}/first.json" +generate_manifest "${output_dir}" >/dev/null +python3 - "${fixture_root}/first.json" "${output_dir}/manifest.json" <<'PY' +import json +import pathlib +import sys + +first = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8")) +second = json.loads(pathlib.Path(sys.argv[2]).read_text(encoding="utf-8")) +assert first["deterministic"] == second["deterministic"] +PY + +independent_output_dir="${report_dir}/release_manifest/four_state_only" +mkdir -p "${report_dir}/four_state_qualification" +printf 'SKIP\n' >"${report_dir}/negative_qualification/status.txt" +printf 'PASS\n' >"${report_dir}/four_state_qualification/status.txt" +printf '{"campaign":"four_state","status":"PASS"}\n' \ + >"${report_dir}/four_state_qualification/summary.json" +generate_manifest "${independent_output_dir}" \ + DISABLED_FLOWS="negative_qualification openroad" >/dev/null +python3 - "${independent_output_dir}/manifest.json" <<'PY' +import json +import pathlib +import sys + +manifest = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8")) +deterministic = manifest["deterministic"] +statuses = {flow["id"]: flow["status"] for flow in deterministic["flows"]} +assert statuses["negative_qualification"] == "SKIP" +assert statuses["four_state_qualification"] == "PASS" +input_paths = {item["path"] for item in deterministic["inputs"]} +assert "config/qualification-four-state.txt" in input_paths +assert "config/qualification-negative.txt" not in input_paths +additional_paths = { + item["path"] for item in deterministic["evidence"]["additional"] +} +assert "reports/four_state_qualification/summary.json" in additional_paths +assert "reports/negative_qualification/summary.json" not in additional_paths +tool_names = {tool["name"] for tool in deterministic["tools"]} +assert {"iverilog", "vvp"} <= tool_names +PY +printf 'PASS\n' >"${report_dir}/negative_qualification/status.txt" +printf 'SKIP\n' >"${report_dir}/four_state_qualification/status.txt" + +expect_failure "Cannot inspect methodology source tree" \ + generate_packaged_manifest "${output_dir}" +generate_packaged_manifest "${output_dir}" \ + RELEASE_METHODOLOGY_DIRTY=false >/dev/null +expect_failure "methodology dirty state must be true or false" \ + generate_packaged_manifest "${output_dir}" \ + RELEASE_METHODOLOGY_DIRTY=unknown + +generate_manifest "${output_dir}" \ + CI=false MODULE_REVISION= >/dev/null +expect_failure "module revision must be supplied explicitly in CI" \ + generate_manifest "${output_dir}" CI=true MODULE_REVISION= +expect_failure "module revision must be supplied explicitly in CI" \ + generate_manifest "${output_dir}" CI=1 MODULE_REVISION= + +printf 'FAIL\n' >"${report_dir}/verible_lint/status.txt" +expect_failure "Required flow verible_lint has status FAIL" \ + generate_manifest "${output_dir}" +test "$(<"${output_dir}/status.txt")" = FAIL + +printf 'BLOCKED\n' >"${report_dir}/verible_lint/status.txt" +expect_failure "Required flow verible_lint has status BLOCKED" \ + generate_manifest "${output_dir}" + +rm "${report_dir}/verible_lint/status.txt" +expect_failure "Required flow verible_lint is missing status evidence" \ + generate_manifest "${output_dir}" +printf 'PASS\n' >"${report_dir}/verible_lint/status.txt" + +mkdir -p "${report_dir}/openroad" +printf 'SKIP\n' >"${report_dir}/openroad/status.txt" +generate_manifest "${output_dir}" >/dev/null +python3 - "${output_dir}/manifest.json" <<'PY' +import json +import pathlib +import sys + +manifest = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8")) +openroad = next( + flow for flow in manifest["deterministic"]["flows"] + if flow["id"] == "openroad" +) +assert openroad["status"] == "SKIP" +assert openroad["status_evidence"]["sha256"] +PY + +printf 'PASS\n' >"${report_dir}/openroad/status.txt" +expect_failure "Disabled flow openroad has status PASS" \ + generate_manifest "${output_dir}" +rm "${report_dir}/openroad/status.txt" + +rm "${report_dir}/qualification/status.txt" +expect_failure "Supplemental gate qualification is missing status evidence" \ + generate_manifest "${output_dir}" +printf 'PASS\n' >"${report_dir}/qualification/status.txt" + +printf 'SKIP\n' >"${report_dir}/verible_lint/status.txt" +expect_failure "Required flow verible_lint has status SKIP" \ + generate_manifest "${output_dir}" +printf 'PASS\n' >"${report_dir}/verible_lint/status.txt" + +printf 'FAIL\n' >"${report_dir}/qualification/status.txt" +expect_failure "Supplemental gate qualification has status FAIL" \ + generate_manifest "${output_dir}" +printf 'PASS\n' >"${report_dir}/qualification/status.txt" + +rm "${report_dir}/qualification/summary.json" +expect_failure "Required release evidence is missing" \ + generate_manifest "${output_dir}" +printf '{"review":"complete"}\n' >"${report_dir}/qualification/summary.json" + +expect_failure "module revision must contain exactly 40 hex digits" \ + generate_manifest "${output_dir}" MODULE_REVISION=bad-revision +expect_failure "Required release input is missing" \ + generate_manifest "${output_dir}" \ + "RELEASE_INPUT_FILES=${module_root}/config/missing.mk" + +touch "${module_root}/dirty-marker" +expect_failure "Dirty source tree detected for module" \ + generate_manifest "${output_dir}" +generate_manifest "${output_dir}" RELEASE_ALLOW_DIRTY=enabled >/dev/null +python3 - "${output_dir}/manifest.json" <<'PY' +import json +import pathlib +import sys + +manifest = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8")) +assert manifest["volatile"]["source_tree"]["module_dirty"] is True +PY + +rm "${module_root}/dirty-marker" +touch "${methodology_root}/dirty-marker" +expect_failure "Dirty source tree detected for methodology" \ + generate_manifest "${output_dir}" + +echo "Release manifest ${fixture_context} fixture passed" diff --git a/tests/test_static_intent.sh b/tests/test_static_intent.sh new file mode 100755 index 0000000..557ea63 --- /dev/null +++ b/tests/test_static_intent.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +set -euo pipefail + +flow_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +fixture_root="${flow_root}/tests/fixtures/static-intent" +report_root="$(mktemp -d)" +trap 'rm -rf "${report_root}"' EXIT + +run_positive() { + local name="$1" + python3 "${flow_root}/ci/static_intent.py" \ + --config "${fixture_root}/config/${name}.json" \ + --module-root "${fixture_root}" \ + --output-dir "${report_root}/${name}" + test "$(<"${report_root}/${name}/status.txt")" = PASS + grep -Fq '"status": "PASS"' "${report_root}/${name}/summary.json" +} + +expect_failure() { + local name="$1" + local report="$2" + local code="$3" + if python3 "${flow_root}/ci/static_intent.py" \ + --config "${fixture_root}/config/${name}.json" \ + --module-root "${fixture_root}" \ + --output-dir "${report_root}/${name}" \ + >"${report_root}/${name}.log" 2>&1; then + echo "Static-intent fixture ${name} unexpectedly passed" >&2 + exit 1 + fi + test "$(<"${report_root}/${name}/status.txt")" = FAIL + grep -Fq "\"code\": \"${code}\"" \ + "${report_root}/${name}/${report}-findings.json" +} + +run_positive always-on +run_positive complete +run_positive isolation +run_positive level-shifter + +expect_failure missing-constraint sdc missing_command +expect_failure incomplete-max-delay sdc incomplete_max_delay +expect_failure broad-exception sdc broad_exception +expect_failure duplicate-command sdc duplicate_command +expect_failure conflicting-command sdc conflicting_command +expect_failure unsupported-command sdc invalid_sdc_intent +expect_failure profile-mismatch sdc profile_mismatch +expect_failure incomplete-power-state upf incomplete_power_state +expect_failure forbidden-strategy upf forbidden_strategy + +adapter_reports="${report_root}/adapter-reports" +adapter_environment=( + "MODULE_ROOT=${fixture_root}" + "FLOW_ROOT=${flow_root}" + "REPORT_DIR=${adapter_reports}" + "MOSAIC_FLOW_IDS=static_intent" + "FLOW_static_intent=enabled" + "FLOW_DEPENDENCIES_static_intent=" + "STATIC_INTENT_TOOL=${flow_root}/ci/static_intent.py" + "STATIC_INTENT_CONFIG=${fixture_root}/config/complete.json" +) +env "${adapter_environment[@]}" \ + "${flow_root}/ci/run_flow.sh" static_intent \ + "${flow_root}/flows/static_intent/run.sh" >/dev/null +test "$(<"${adapter_reports}/static_intent/status.txt")" = PASS + +if env "${adapter_environment[@]}" \ + "STATIC_INTENT_CONFIG=${fixture_root}/config/missing-constraint.json" \ + "${flow_root}/ci/run_flow.sh" static_intent \ + "${flow_root}/flows/static_intent/run.sh" >/dev/null 2>&1; then + echo "Static-intent flow adapter accepted failing intent" >&2 + exit 1 +fi +test "$(<"${adapter_reports}/static_intent/status.txt")" = FAIL + +skip_reports="${report_root}/skip-reports" +env \ + MODULE_ROOT="${fixture_root}" FLOW_ROOT="${flow_root}" \ + REPORT_DIR="${skip_reports}" MOSAIC_FLOW_IDS=static_intent \ + FLOW_static_intent=disabled FLOW_DEPENDENCIES_static_intent= \ + DISABLED_FLOWS=static_intent \ + "${flow_root}/ci/run_flow.sh" static_intent false >/dev/null +test "$(<"${skip_reports}/static_intent/status.txt")" = SKIP + +echo "Portable static-intent positive and negative fixtures passed"