diff --git a/.github/constraints-ci.txt b/.github/constraints-ci.txt new file mode 100644 index 00000000..a5c33ee9 --- /dev/null +++ b/.github/constraints-ci.txt @@ -0,0 +1,3 @@ +torch==2.6.0 +pytest==9.1.1 +ruff==0.16.4 diff --git a/.github/scripts/check_doc_links.py b/.github/scripts/check_doc_links.py new file mode 100755 index 00000000..d7ce1bab --- /dev/null +++ b/.github/scripts/check_doc_links.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +"""Check *inline* relative Markdown links under the given roots. + +Scope, stated precisely because a checker that overstates its coverage is worse +than one that admits its limits: + + handled inline links -- [text](path), [text](), [text](path "title"), + with an optional #fragment; fenced code blocks are skipped + NOT handled reference-style links ([text][ref] + [ref]: path), destinations + spanning multiple lines, destinations containing balanced + parentheses, single-quoted or parenthesised titles, and + percent-encoded paths + +Anything in the "not handled" list is invisible to this check, not tolerated by +it. The Documentation/ corpus contains none of those forms today (verified: 0 +fenced links, 0 reference definitions); widen this script before relying on it +for a tree that does. +""" +from __future__ import annotations + +import re +import sys +from pathlib import Path + +LINK = re.compile(r"\[[^\]]*\]\(\s*\s]+)>?(?:\s+\"[^\"]*\")?\s*\)") +# Fence opener/closer. Written with `{3,}` rather than literal backticks so this +# file can itself be embedded in a Markdown fence without terminating it. +FENCE = re.compile(r"^\s{0,3}(`{3,}|~{3,})\s*(.*)$") +EXTERNAL = ("http://", "https://", "mailto:", "tel:", "ftp://", "//") + + +def iter_prose(text: str): + """Yield (lineno, line) for lines outside fenced code blocks. + + Tracks the opening fence's character and length: a fence closes only on the + same character, at least as long, and with no trailing info string. Without + that, a ``~~~`` block containing a triple backtick toggles the state and the + rest of the file is misclassified. + """ + fence_char: str | None = None + fence_len = 0 + for lineno, line in enumerate(text.splitlines(), 1): + match = FENCE.match(line) + if match: + run, info = match.group(1), match.group(2).strip() + if fence_char is None: + fence_char, fence_len = run[0], len(run) + continue + if run[0] == fence_char and len(run) >= fence_len and not info: + fence_char, fence_len = None, 0 + continue + # a shorter/different run inside a fence is content, not a closer + continue + if fence_char is None: + yield lineno, line + + +def main(roots: list[str]) -> int: + repo = Path(__file__).resolve().parents[2] + broken, checked = [], 0 + for root in roots: + for md in sorted((repo / root).rglob("*.md")): + text = md.read_text(encoding="utf-8", errors="replace") + for lineno, line in iter_prose(text): + for href in LINK.findall(line): + if href.startswith(EXTERNAL) or href.startswith("#"): + continue + target = href.split("#", 1)[0] + if not target: + continue + checked += 1 + if (md.parent / target).exists(): + continue + if (repo / target.lstrip("/")).exists(): + continue + broken.append(f"{md.relative_to(repo)}:{lineno} -> {href}") + + print(f"checked {checked} inline relative link(s) in: {', '.join(roots)}") + if broken: + print(f"\n{len(broken)} broken link(s):") + for b in broken: + print(f" {b}") + return 1 + print("all inline relative links resolve") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:] or ["Documentation"])) diff --git a/.github/scripts/forge_lint_gate.py b/.github/scripts/forge_lint_gate.py new file mode 100755 index 00000000..e84a4070 --- /dev/null +++ b/.github/scripts/forge_lint_gate.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""Fail closed on `forge lint --json` output. + +Reads JSON-lines diagnostics on stdin and exits non-zero if anything is wrong. +This is a required, security-sensitive gate, so it validates the *schema* it was +written against rather than only the JSON syntax: + + * an unparseable line -> fail + * an unrecognised `$message_type` -> fail (forge's output changed) + * a diagnostic missing `level` or a code -> fail (schema drifted) + * a diagnostic whose `level` is unknown -> fail (new severity, unclassified) + * any diagnostic at `warning` or `error` -> fail (the actual findings) + +Empty input is valid and means a clean tree. The caller must check forge's own +exit status separately; this script never sees it. + +Pinned against Foundry v1.7.1, whose `forge lint --json` emits only +`$message_type: "diagnostic"` records. A new record type is treated as a +breaking change to be reviewed, not as something to skip silently. +""" +from __future__ import annotations + +import json +import sys +from collections import Counter + +# Severities forge can emit, partitioned into what fails and what does not. +FAIL_LEVELS = frozenset({"warning", "error"}) +PASS_LEVELS = frozenset({"note", "help", "info"}) +KNOWN_LEVELS = FAIL_LEVELS | PASS_LEVELS + +# Record types this parser was written against. +KNOWN_MESSAGE_TYPES = frozenset({"diagnostic"}) + + +class SchemaError(Exception): + """forge's output does not match what this gate was written against.""" + + +def _diagnostic_code(record: dict) -> str: + code = record.get("code") + if not isinstance(code, dict): + raise SchemaError("diagnostic has no `code` object") + value = code.get("code") + if not isinstance(value, str) or not value: + raise SchemaError("diagnostic `code.code` is missing or not a string") + return value + + +def _primary_location(record: dict) -> str: + for span in record.get("spans") or []: + if isinstance(span, dict) and span.get("is_primary"): + return f"{span.get('file_name')}:{span.get('line_start')}:{span.get('column_start')}" + return "" + + +def scan(stream) -> tuple[Counter, int]: + """Return (failing counts by level[code], number of diagnostics seen). + + Raises SchemaError on anything unrecognised. + """ + failing: Counter[str] = Counter() + seen = 0 + for lineno, raw in enumerate(stream, 1): + raw = raw.strip() + if not raw: + continue + try: + record = json.loads(raw) + except json.JSONDecodeError as exc: + raise SchemaError(f"line {lineno}: unparseable JSON ({exc})") from exc + if not isinstance(record, dict): + raise SchemaError(f"line {lineno}: expected a JSON object, got {type(record).__name__}") + + message_type = record.get("$message_type") + if message_type not in KNOWN_MESSAGE_TYPES: + raise SchemaError( + f"line {lineno}: unrecognised $message_type {message_type!r} — " + "forge's lint output has changed; review before trusting this gate" + ) + + seen += 1 + level = record.get("level") + if not isinstance(level, str) or not level: + raise SchemaError(f"line {lineno}: diagnostic has no `level`") + if level not in KNOWN_LEVELS: + raise SchemaError( + f"line {lineno}: unknown severity {level!r} — classify it in " + "FAIL_LEVELS or PASS_LEVELS before trusting this gate" + ) + code = _diagnostic_code(record) + + if level in FAIL_LEVELS: + failing[f"{level}[{code}]"] += 1 + print(f"{_primary_location(record)}: {level}[{code}] {record.get('message')}") + + return failing, seen + + +def main() -> int: + try: + failing, seen = scan(sys.stdin) + except SchemaError as exc: + print(f"::error::forge lint output failed validation: {exc}") + return 1 + + if failing: + total = sum(failing.values()) + print(f"\n::error::forge lint reported {total} finding(s) at {sorted(FAIL_LEVELS)}") + for key, count in sorted(failing.items(), key=lambda kv: (-kv[1], kv[0])): + print(f" {count:4d} {key}") + return 1 + + print(f"forge lint: {seen} diagnostic(s), none at {sorted(FAIL_LEVELS)}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..f67904cb --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,163 @@ +name: CI + +on: + pull_request: + branches: [develop] + +permissions: + contents: read + +concurrency: + group: ci-${{ github.event.pull_request.number }} + cancel-in-progress: true + +env: + # Versions this pipeline was measured against. See plan 2.4 / 2.9. + FOUNDRY_VERSION: v1.7.1 + PIP_CONSTRAINT: .github/constraints-ci.txt + +# Actions already defaults to bash on Linux, but ci-ok's result loop relies on +# word splitting, which zsh (and dash) do not do. Stated rather than assumed. +defaults: + run: + shell: bash + +jobs: + solidity: + name: Solidity + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + submodules: recursive # forge-std + 3 OZ libs; see plan 2.3 + fetch-depth: 1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 20 + cache: npm + cache-dependency-path: | + foundry/package-lock.json + hardhat/package-lock.json + - uses: foundry-rs/foundry-toolchain@908c540300062bd5a7e473851cdb4282204cee09 # v1.9.1 + with: + # Pinned rather than `stable`: forge's diagnostics are parsed by the + # lint gate and its compiler is blocking. See plan 2.9. + version: ${{ env.FOUNDRY_VERSION }} + + - name: Record the toolchain actually installed + run: forge --version + + # Must precede `forge test`: Upgrades.validateImplementation shells out to + # @openzeppelin/upgrades-core over FFI, and letting npx fetch it at test + # time races across the parallel tests. See plan 2.2. + - name: Install foundry npm deps + working-directory: foundry + run: npm ci + + - name: forge build + working-directory: foundry + run: forge build + + - name: forge test + working-directory: foundry + run: forge test + + # Advisory until PR 3. `--json` writes diagnostics to stderr and cannot be + # combined with `--color`; the gate fails closed on a nonzero forge status, + # an unparseable stream, or any warning/error. See plan 2.7. + - name: forge lint (advisory) + working-directory: foundry + continue-on-error: true + run: | + set +e + forge lint --json 2> "$RUNNER_TEMP/lint.json" + forge_status=$? + set -e + echo "forge lint exit status: $forge_status" + if [ "$forge_status" -ne 0 ]; then + echo "::error::forge lint itself failed" + cat "$RUNNER_TEMP/lint.json" || true + exit 1 + fi + python3 ../.github/scripts/forge_lint_gate.py < "$RUNNER_TEMP/lint.json" + + - name: Install hardhat deps + working-directory: hardhat + run: npm ci + + - name: hardhat compile + working-directory: hardhat + run: npx hardhat compile + + - name: hardhat test + working-directory: hardhat + run: npx hardhat test + + python: + name: Python + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 1 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.12' + cache: pip + + # CPU wheel is 178 MB; the default index resolves a ~4 GB CUDA stack that + # nothing here uses. The version comes from PIP_CONSTRAINT. See plan 2.4. + - name: Install torch (CPU) + run: python -m pip install --index-url https://download.pytorch.org/whl/cpu torch + + - name: Install dincli + test deps + run: python -m pip install -e ".[test]" + + - name: ruff (advisory) + continue-on-error: true + run: | + python -m pip install ruff + ruff check . + + # Excludes tests/dincli/ (Docker + IPFS + live chain) via the directory + # hook. pytest exits 5 if this ever selects nothing, so a broken hook + # fails rather than passes silently. See plan 2.6. + - name: pytest + run: pytest -m "not integration" -q + + docs: + name: Docs + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 1 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.12' + - name: Relative link check + run: python .github/scripts/check_doc_links.py Documentation + + # The only required status check. Keeps branch protection to one context and + # avoids the path-filter/required-check deadlock. See plan 2.8. + ci-ok: + name: CI OK + if: always() + needs: [solidity, python, docs] + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Verify every job succeeded + run: | + results="${{ join(needs.*.result, ' ') }}" + echo "job results: $results" + for r in $results; do + if [ "$r" != "success" ]; then + echo "::error::a required job did not succeed ($r)" + exit 1 + fi + done + echo "all jobs succeeded" diff --git a/.gitignore b/.gitignore index d92a2ac6..e5097015 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,11 @@ tasks/local/0x4657105FC932625CD289107aAE7B2174a822b709/services/__pycache__/* /dincli/README.md foundry/.github/* +# Bytecode anywhere. The three path-specific rules above predate this and are +# now redundant; left in place to keep this diff small. Needed because +# .github/scripts/ is imported by tests, which was not a pycache site before. +__pycache__/ + # build artifacts / local envs picked up while making the tree CI-ready. # foundry/out, foundry/cache, hardhat/artifacts, hardhat/cache and # hardhat/typechain-types are already covered by foundry/.gitignore and diff --git a/tests/test_ci_scripts.py b/tests/test_ci_scripts.py new file mode 100644 index 00000000..17c2ec34 --- /dev/null +++ b/tests/test_ci_scripts.py @@ -0,0 +1,153 @@ +"""Tests for the CI helper scripts in .github/scripts/. + +Both become required gates, so they get the same treatment as the code they +guard. The forge-lint gate in particular must fail closed: a parser that +silently skips what it does not understand turns a security check into a +rubber stamp. +""" +import io +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path + +import pytest + +SCRIPTS = Path(__file__).resolve().parents[1] / ".github" / "scripts" + + +def _load(name): + path = SCRIPTS / name + spec = spec_from_file_location(f"ci_scripts_{path.stem}", path) + module = module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +gate = _load("forge_lint_gate.py") +links = _load("check_doc_links.py") + + +# -------------------------------------------------------------------------- +# forge_lint_gate: fail closed +# -------------------------------------------------------------------------- + +DIAG = ( + '{{"$message_type":"diagnostic","level":"{level}",' + '"code":{{"code":"{code}"}},"message":"m",' + '"spans":[{{"file_name":"src/A.sol","line_start":1,"column_start":2,"is_primary":true}}]}}' +) + + +def _scan(*lines): + return gate.scan(io.StringIO("\n".join(lines))) + + +def test_empty_input_is_a_clean_tree(): + failing, seen = _scan() + assert not failing and seen == 0 + + +def test_blank_lines_are_ignored(): + failing, seen = _scan("", " ", "") + assert not failing and seen == 0 + + +@pytest.mark.parametrize("level", ["warning", "error"]) +def test_failing_levels_are_counted(level): + failing, seen = _scan(DIAG.format(level=level, code="unsafe-typecast")) + assert seen == 1 + assert failing == {f"{level}[unsafe-typecast]": 1} + + +@pytest.mark.parametrize("level", ["note", "help", "info"]) +def test_non_failing_levels_are_seen_but_not_counted(level): + failing, seen = _scan(DIAG.format(level=level, code="whatever")) + assert seen == 1 + assert not failing + + +def test_unknown_message_type_is_rejected(): + """A new record type means forge's output changed; do not skip it.""" + with pytest.raises(gate.SchemaError, match="unrecognised .message_type"): + _scan('{"$message_type":"changed-schema","level":"warning"}') + + +def test_diagnostic_without_level_is_rejected(): + with pytest.raises(gate.SchemaError, match="no `level`"): + _scan('{"$message_type":"diagnostic"}') + + +def test_diagnostic_with_unknown_level_is_rejected(): + with pytest.raises(gate.SchemaError, match="unknown severity"): + _scan( + '{"$message_type":"diagnostic","level":"catastrophe",' + '"code":{"code":"x"}}' + ) + + +def test_diagnostic_without_code_is_rejected(): + with pytest.raises(gate.SchemaError, match="no `code` object"): + _scan('{"$message_type":"diagnostic","level":"warning"}') + + +def test_unparseable_line_is_rejected(): + with pytest.raises(gate.SchemaError, match="unparseable JSON"): + _scan("not json at all") + + +def test_non_object_json_is_rejected(): + with pytest.raises(gate.SchemaError, match="expected a JSON object"): + _scan("[1, 2, 3]") + + +def test_missing_primary_span_still_reports(): + failing, _ = _scan( + '{"$message_type":"diagnostic","level":"warning",' + '"code":{"code":"x"},"spans":[]}' + ) + assert failing == {"warning[x]": 1} + + +# -------------------------------------------------------------------------- +# check_doc_links: fence tracking +# -------------------------------------------------------------------------- + + +def _prose(text): + return [line for _lineno, line in links.iter_prose(text)] + + +# Fences are built from explicit repeats rather than written literally: it keeps +# the run lengths visible in each test, and lets this file be quoted inside a +# Markdown fence without terminating it. +BT3, BT4 = "`" * 3, "`" * 4 +TL3 = "~" * 3 + + +def test_backtick_fence_contents_are_skipped(): + assert _prose(f"a\n{BT3}\nhidden\n{BT3}\nb") == ["a", "b"] + + +def test_tilde_fence_is_not_closed_by_a_backtick_run(): + """The naive toggle mis-closed here and leaked the rest of the file.""" + assert _prose(f"a\n{TL3}\ncontains {BT3} inside\nhidden\n{TL3}\nb") == ["a", "b"] + + +def test_longer_fence_treats_shorter_inner_runs_as_content(): + assert _prose(f"a\n{BT4}\n{BT3}\ninner\n{BT3}\n{BT4}\nb") == ["a", "b"] + + +def test_closing_fence_may_not_carry_an_info_string(): + assert _prose(f"a\n{BT3}\nhidden\n{BT3} python\nhidden\n{BT3}\nb") == ["a", "b"] + + +def test_unterminated_fence_swallows_the_rest(): + assert _prose(f"a\n{BT3}\nhidden\nalso hidden") == ["a"] + + +def test_indented_fence_up_to_three_spaces_counts(): + assert _prose(f"a\n {BT3}\nhidden\n {BT3}\nb") == ["a", "b"] + + +def test_prose_is_yielded_when_there_are_no_fences(): + assert _prose("one\ntwo") == ["one", "two"]