From 2d253802b51cbffd70e673acb1abea254d21d515 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Sat, 26 Sep 2026 06:15:06 +0800 Subject: [PATCH] docs(contributing): make first-contribution cost visible and smaller - state the Node.js >= 22.22.3 runtime in pyproject metadata and in the CONTRIBUTING prerequisites, with npm ci as a one-time setup step - collapse the missing-npm-dependency failures into one named remedy: the TypeScript semantic scan detects a missing typescript package or node binary, and pytest prints one loopx setup line at the end - map all 14 workflows to trigger, merge blocking and purpose; only Sign-off and merge-gate block a merge - define when a change needs an RFC and how a compact RFC is sized - add a Code Map convention for capability READMEs, starting with decision_context, guarded so a map cannot go stale - guard Node minimum consistency across README, CONTRIBUTING, pyproject, install guide, entrypoint, package.json and workflow pins Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Co-authored-by: Cursor --- CONTRIBUTING.md | 79 ++++++++++++++++++- docs/architecture/rfcs/README.md | 24 ++++++ loopx/capabilities/README.md | 9 +++ loopx/capabilities/decision_context/README.md | 38 +++++++++ .../decision_context/README.zh-CN.md | 35 ++++++++ loopx/semantics/production.py | 15 +++- pyproject.toml | 4 +- .../architecture/test_semantic_production.py | 22 ++++++ tests/conftest.py | 32 ++++++++ tests/test_capability_code_maps.py | 52 ++++++++++++ tests/test_contributing_guide.py | 75 ++++++++++++++++++ 11 files changed, 379 insertions(+), 6 deletions(-) create mode 100644 tests/test_capability_code_maps.py create mode 100644 tests/test_contributing_guide.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b4adcbee65..92e0e18bee 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -79,14 +79,44 @@ Before adding or consolidating a public smoke, use the bilingual [good smoke guide](docs/development/good-smokes.md) to define its durable invariant, independent oracle, cadence, and public-safe fixture boundary. -For source development, run commands from the repository or dedicated worktree -root with `uv`. It manages a compatible Python and installs the current checkout -in the project environment, keeping checks separate from a globally installed -LoopX release. See the [local validation commands](docs/development/testing-and-quality.md#local-validation-environment--本地验证环境) +### Prerequisites and one-time setup + +LoopX needs two runtimes: + +- **Python 3.11+.** `uv` installs a compatible one for you. +- **Node.js 22.22.3 or newer**, with Node.js 24 LTS recommended. `pyproject.toml` + declares no Python dependencies, but the TypeScript control plane runs on the + system Node.js, both for `loopx` itself and for the test suite. `pip` and `uv` + cannot install Node.js for you. + +Run commands from the repository or dedicated worktree root with `uv`. It +installs the current checkout in the project environment, which keeps your +checks separate from any globally installed LoopX release. See the +[local validation commands](docs/development/testing-and-quality.md#local-validation-environment--本地验证环境) for environment, lockfile, and CI boundaries. ```bash uv sync --extra test +npm ci --ignore-scripts # TypeScript compiler and test dependencies +``` + +Run `npm ci` once per checkout or worktree. The Python architecture tests parse +TypeScript with the repository's `typescript` package, so without it they fail. +In that case pytest prints a single `loopx setup` line that names this command. + +### Fast loop and full check + +While iterating, run only what your change touches: + +```bash +uv run --extra test python -m pytest -q +npm run -s typecheck:control-plane && npm run -s test:control-plane # when you changed *.ts +uv run --extra test loopx canary premerge --from-git-diff # selects smokes for your diff +``` + +Before pushing, run the full local equivalent of CI: + +```bash uv run --extra test python -m ruff check tests loopx/canary loopx/control_plane loopx/domain_packs loopx/presentation uv run --extra test python -m mypy uv run --extra test python examples/control_plane/cli-output-budget-regression-smoke.py @@ -100,6 +130,47 @@ Choose focused smokes and broader canaries by change risk; do not run every public smoke or a live model call for every patch. The quality guide explains the CI, local/manual, and release-only boundaries. +### What CI runs on a pull request + +Only two checks block a merge: + +- `Sign-off`, the DCO check; +- `merge-gate`, which aggregates the Python Tests workflow. + +`merge-gate` stays green only when these Python Tests jobs succeed or were +correctly skipped for your paths: `checks`, `pytest`, +`node-minimum-compatibility`, `stage2c-correctness-e2e`, `windows-powershell`, +and `presentation`. The other workflows are path-filtered, advisory, or do not +run on pull requests. If an advisory workflow fails on a path you did not +touch, mention it in the PR instead of fixing it there. + +| Workflow file | Runs on a PR | Blocks merge | What it checks | +| --- | --- | --- | --- | +| `python-tests.yml` | every PR | yes (`merge-gate`) | lint, mypy, sharded pytest, TypeScript core and coverage, minimum Node.js, Windows PowerShell, dashboard presentation | +| `dco.yml` | every PR | yes (`Sign-off`) | `Signed-off-by` trailer on every commit | +| `dependency-review.yml` | every PR | no | dependency changes introduced by the PR | +| `postgresql-integration.yml` | control-plane or npm lockfile paths | no | PostgreSQL authority store and service on a temporary instance | +| `package-smoke.yml` | extension package paths | no | extension packages install, entrypoints, and example schemas | +| `release-artifacts.yml` | `loopx/`, packaging, and lockfile paths | no | release identity and a release build from this source | +| `ark-turn.yml` | Turn driver and collaboration paths | no | optional Ark Turn package, stdio MCP, and DSH parity | +| `frontstage-pages.yml` | README, dashboard, and chat bundle paths | no | public Pages build | +| `desktop-release-artifacts.yml` | desktop app and dashboard paths | no | macOS and Windows desktop builds | +| `desktop-updater.yml` | desktop app paths | no | desktop app build and updater feed | +| `full-public-smokes.yml` | no (push to `main`, schedule) | no | every public smoke, in shards | +| `sonarcloud.yml` | no (called by Python Tests) | no | SonarCloud analysis of that run's coverage | +| `stale.yml` | no (schedule) | no | stale-issue reminders (never closes issues) | +| `update-notes.yml` | no (schedule) | no | biweekly update notes | + +### Design notes and RFCs + +Most changes do not need an RFC. For example, adding a backward-compatible +field to an existing projection or packet needs no RFC: update the owning +reference contract or capability README, and put the design note in the PR +description. See +[when a change needs an RFC](docs/architecture/rfcs/README.md#when-a-change-needs-an-rfc). +Before extending a capability, read the **Code map** in its README, where one +exists, instead of reading the whole package. + ## License And DCO Sign-Off LoopX's unified open source core is licensed under the diff --git a/docs/architecture/rfcs/README.md b/docs/architecture/rfcs/README.md index 4ab79e0c49..735fcaaa54 100644 --- a/docs/architecture/rfcs/README.md +++ b/docs/architecture/rfcs/README.md @@ -9,6 +9,30 @@ Start new proposals from the [RFC template](TEMPLATE.md). Existing RFCs should adopt its maintenance contract when substantial revision would otherwise mix stable design, current progress, and historical evidence. +### When a change needs an RFC + +Write an RFC, or amend the normative sections of an existing one, when a +change does any of the following: + +- creates, moves, or removes an owner or source of authority; +- changes default behavior; +- makes a state or schema change that is not purely additive; +- introduces an irreversible or multi-step migration; +- defines a contract that several capabilities or hosts must follow. + +Most changes do not need an RFC. Examples are an additive, backward-compatible +field on an existing projection or packet; a bug fix that restores documented +behavior; and new tests, smokes, diagnostics, or documentation. For these, +update the owning [reference contract](../../reference/contracts/README.md) or +capability README, and put a short design note in the pull request. The note +covers the problem, the field semantics, compatibility, and validation. + +An RFC is sized by its decision, not by the template. The template's twelve +sections are a checklist. A section that cannot apply takes one line, +`Not applicable: `, and an appendix is added only when it first has +content. The Chinese mirror follows the same compact shape and stays +synchronized for normative sections. + The [Current Technical Directions](../../project/technical-directions.md) page maps RFCs to strategic programs, contribution routes, and promotion gates. diff --git a/loopx/capabilities/README.md b/loopx/capabilities/README.md index 16cf04420b..6ba7f00bc1 100644 --- a/loopx/capabilities/README.md +++ b/loopx/capabilities/README.md @@ -95,6 +95,15 @@ loopx capability show --format json boundaries, protocols, smokes, and provider readiness from the same registered record used to build the documentation site. +A capability README can also carry a `## Code Map` section. This is a table +with one row per module that states which step it owns, followed by the files +to touch for common changes. It lets a contributor add a field without first +reading the whole package. Once a README has a code map, +`tests/test_capability_code_maps.py` requires the map to list every module in +the package, and the Chinese mirror to name the same modules, so the map +cannot silently go stale. Add a code map when a package grows past a handful of +modules. + Supporting packages such as shared context-provider helpers may live under this namespace without a `catalog_entry.py`; they are internal modules, not product capabilities. Optional providers and extension-delivered capabilities diff --git a/loopx/capabilities/decision_context/README.md b/loopx/capabilities/decision_context/README.md index fe51f17da3..03c0793526 100644 --- a/loopx/capabilities/decision_context/README.md +++ b/loopx/capabilities/decision_context/README.md @@ -92,6 +92,44 @@ the caller must label the conclusion as partial or exact-read the missing authority through another path. Fail-open must not masquerade as complete context coverage. +## Code Map + +A decision runs through the modules in this order. Read only the rows your +change touches. + +| Module | Owns | +|---|---| +| `profile.py` | Default-off, goal-scoped profile: which source classes matter, freshness policy, scan mode, weight; activation status | +| `providers.py` | Registry of replaceable current-authority providers, plus the local-file provider | +| `sources.py` | Provider-neutral source contracts: specs, items, scans, exact reads, source manifest | +| `runtime.py` | Thin orchestration from profile to providers to evidence assembly and advisory recall | +| `assembler.py` | Deterministic authority rebase, advisory recall assembly, `decision_source_coverage_v0` | +| `packets.py` | Public-safe evidence, proposal, review and outcome packets | +| `review_settlement.py` | Owner-gated or quiet settlement of one assembly | +| `cursor_commit.py` | Validated private cursor commit after settlement | +| `private_state.py` | Private cursor and pending-settlement file IO | +| `outcome_feedback.py` | Audited feedback from outcomes into Reward Memory | +| `capture.py` | Opt-in source-reference capture and `capture-status` | +| `capture_recovery.py` | Reference-preserving capture diagnosis and recovery | +| `extension_provider.py` | Advisory context provider delivered by an extension (`decision_context_advisory_provider_v0`) | +| `architecture.py` | `architecture` readback of the capability contract | +| `catalog_entry.py` | Capability catalog record | +| `cli.py` | Every `loopx decision-context` subcommand and its rendering | + +To add an observable field: + +- **A per-source fact**, such as read time or scan status: produce it in + `sources.py` or the provider in `providers.py`, then carry it into coverage + in `assembler.py`. +- **A decision-level field:** add it in `assembler.py`, and in `packets.py` + only if it belongs in a public packet, where the public-safety checks live. +- **A capture-only fact:** add it in `capture.py`. +- **Exposing it:** `cli.py` renders it. `loopx/cli.py` changes only when a new + top-level dispatch is needed. +- **Documenting and testing it:** update the matching surface in this README + and `README.zh-CN.md`. Test it in `tests/capabilities/test_decision_context_.py` + and, for packet shape, in `examples/decision-context-contract-smoke.py`. + ## Four Auditable Outputs | Output | Answers | Typical contents | diff --git a/loopx/capabilities/decision_context/README.zh-CN.md b/loopx/capabilities/decision_context/README.zh-CN.md index c480df80e6..8de9c26b67 100644 --- a/loopx/capabilities/decision_context/README.zh-CN.md +++ b/loopx/capabilities/decision_context/README.zh-CN.md @@ -80,6 +80,41 @@ exact-read 完整度和未覆盖的 P0 source 投影为公开安全的回执。` 不阻断安全的 LoopX lifecycle,但调用方必须显式标记结论为部分覆盖,或者先通过 其他 authority 路径补齐 exact read;不能把 fail-open 误写成“所有关键上下文已检查”。 +## 代码地图 + +一次决策按下表顺序经过各模块;只读你的改动涉及的那几行即可。 + +| 模块 | 负责 | +|---|---| +| `profile.py` | 默认关闭、goal 级的 profile:关注哪些信源类别、新鲜度策略、扫描模式、权重;激活状态 | +| `providers.py` | 可替换的 current-authority provider 注册表,以及本地文件 provider | +| `sources.py` | provider 中立的信源合同:spec、item、scan、exact read、source manifest | +| `runtime.py` | 从 profile 到 provider 再到 evidence assembly 与 advisory recall 的薄编排层 | +| `assembler.py` | 确定性的 authority rebase、advisory recall 装配、`decision_source_coverage_v0` | +| `packets.py` | 公开安全的 evidence、proposal、review、outcome packet | +| `review_settlement.py` | 对单次 assembly 做 owner 把关或 quiet settlement | +| `cursor_commit.py` | settlement 校验通过后提交私有 cursor | +| `private_state.py` | 私有 cursor 与 pending settlement 的文件读写 | +| `outcome_feedback.py` | 从 outcome 回流到 Reward Memory 的审计反馈 | +| `capture.py` | opt-in 的 source-reference capture 与 `capture-status` | +| `capture_recovery.py` | 保留引用的 capture 诊断与恢复 | +| `extension_provider.py` | 由扩展交付的 advisory context provider(`decision_context_advisory_provider_v0`) | +| `architecture.py` | 能力合同的 `architecture` 读回 | +| `catalog_entry.py` | 能力 catalog 记录 | +| `cli.py` | 所有 `loopx decision-context` 子命令及其渲染 | + +新增一个可观测字段时: + +- **单个信源的事实**(如读取时间、扫描状态):在 `sources.py` 或 `providers.py` + 的 provider 里产出,再在 `assembler.py` 带进 coverage。 +- **决策级字段**:加在 `assembler.py`;只有属于公开 packet 时才进 `packets.py`, + 公开安全检查在那里。 +- **只和 capture 有关的事实**:加在 `capture.py`。 +- **对外暴露**:由 `cli.py` 渲染;只有需要新的顶层分发时才改 `loopx/cli.py`。 +- **文档与测试**:更新本 README 和英文 README 对应的入口段落;测试放在 + `tests/capabilities/test_decision_context_.py`,packet 形状用 + `examples/decision-context-contract-smoke.py` 覆盖。 + ## 四类可审计产物 | 产物 | 回答的问题 | 典型内容 | diff --git a/loopx/semantics/production.py b/loopx/semantics/production.py index be065237c9..6f315541d0 100644 --- a/loopx/semantics/production.py +++ b/loopx/semantics/production.py @@ -4,6 +4,7 @@ import ast import json from pathlib import Path +import shutil import subprocess from typing import Any, Callable @@ -11,6 +12,13 @@ from .python_production import Production, enum_members, scan_python_production +# Matched verbatim by tests/conftest.py to collapse repeated failures into one hint. +NPM_DEV_DEPENDENCIES_MISSING = ( + 'repository npm dev dependencies are not installed (the typescript package is missing); ' + 'run `npm ci --ignore-scripts` once from the repository root' +) + + # This source boundary is code owned. It is not adjustable through registry data. PRODUCER_ROOTS = ( 'loopx/cli_commands', 'loopx/control_plane/agents', @@ -120,8 +128,11 @@ def run_typescript_scan( """Run repository semantic AST analysis with one bounded error boundary.""" if not ts_sources: return [] + node = shutil.which('node') + if node is None: + raise ValueError('TypeScript semantic scan needs Node.js on PATH; see the Node.js requirement in CONTRIBUTING.md') completed = subprocess.run( - ['node', str(root / 'scripts/semantic_production_scan.mjs')], + [node, str(root / 'scripts/semantic_production_scan.mjs')], input=json.dumps({**request, 'sources': [{'path': s.path, 'text': s.text} for s in ts_sources]}), capture_output=True, text=True, encoding="utf-8", timeout=60, check=False, ) @@ -137,6 +148,8 @@ def run_typescript_scan( and error.get('path') in {s.path for s in ts_sources} and isinstance(error.get('line'), int) and error['line'] > 0): raise ValueError(f"{error['path']}:{error['line']}: invalid TypeScript source; repair syntax before semantic scanning") + if 'ERR_MODULE_NOT_FOUND' in completed.stderr and "'typescript'" in completed.stderr: + raise ValueError(NPM_DEV_DEPENDENCIES_MISSING) raise ValueError('TypeScript production parser failed; run npm ci --ignore-scripts and check the Node runtime') return json.loads(completed.stdout) diff --git a/pyproject.toml b/pyproject.toml index 9dc6ba5869..d563a45832 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,12 +5,14 @@ build-backend = "setuptools.build_meta" [project] name = "loopx" version = "1.2.0" -description = "A lightweight Loop Engineering control plane for long-running agent goals." +description = "A Loop Engineering control plane for long-running agent goals (requires Node.js 22.22.3+)." readme = "README.md" requires-python = ">=3.11" license = "Apache-2.0" license-files = ["LICENSE", "NOTICE", "LICENSE-MIT"] authors = [{ name = "LoopX contributors" }] +# No Python runtime dependencies, but the TypeScript control plane needs a +# system Node.js >= 22.22.3 (MINIMUM_NODE_VERSION); pip cannot install it. dependencies = [] [project.urls] diff --git a/tests/architecture/test_semantic_production.py b/tests/architecture/test_semantic_production.py index dc80f88bd3..6522c04bc0 100644 --- a/tests/architecture/test_semantic_production.py +++ b/tests/architecture/test_semantic_production.py @@ -311,3 +311,25 @@ def test_reported_sites_carry_a_blocker_label_and_summarise(): # paths a future slice could still resolve. assert 'argument_name_only=' in summary assert 'annotation_only=' in summary + + +def test_typescript_scan_names_the_missing_setup_step(monkeypatch): + import subprocess + + from loopx.semantics import production + + source = [SourceFile('loopx/example.ts', '.ts', 'export const x = 1;\n')] + monkeypatch.setattr(production.shutil, 'which', lambda name: None) + with pytest.raises(ValueError, match='needs Node.js on PATH'): + production.run_typescript_scan(ROOT, source, {}) + + monkeypatch.setattr(production.shutil, 'which', lambda name: '/usr/bin/node') + missing = "Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'typescript' imported from x.mjs" + monkeypatch.setattr( + production.subprocess, 'run', + lambda *args, **kwargs: subprocess.CompletedProcess(args, 1, stdout='', stderr=missing), + ) + with pytest.raises(ValueError) as raised: + production.run_typescript_scan(ROOT, source, {}) + assert str(raised.value) == production.NPM_DEV_DEPENDENCIES_MISSING + assert 'x.mjs' not in str(raised.value), 'parser stderr must never reach public diagnostics' diff --git a/tests/conftest.py b/tests/conftest.py index de7c6dd350..d79da298ad 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,6 +9,7 @@ sys.path.insert(0, str(REPO_ROOT)) from loopx.canary.runner import SMOKE_SUITE_CHOICES # noqa: E402 +from loopx.semantics.production import NPM_DEV_DEPENDENCIES_MISSING # noqa: E402 def pytest_addoption(parser) -> None: @@ -99,3 +100,34 @@ def pytest_addoption(parser) -> None: dest="loopx_smoke_timeout", help="Per-check timeout in seconds for each subprocess smoke.", ) + + +def _typescript_dev_dependency_installed() -> bool: + return any( + (directory / "node_modules" / "typescript" / "package.json").is_file() + for directory in (REPO_ROOT, *REPO_ROOT.parents) + ) + + +def pytest_report_header(config) -> list[str]: + if _typescript_dev_dependency_installed(): + return [] + return [f"loopx: {NPM_DEV_DEPENDENCIES_MISSING}; TypeScript semantic scans will fail"] + + +_SETUP_FAILURES: set[str] = set() + + +def pytest_runtest_logreport(report) -> None: + if report.failed and NPM_DEV_DEPENDENCIES_MISSING in report.longreprtext: + _SETUP_FAILURES.add(report.nodeid) + + +def pytest_unconfigure(config) -> None: + # Runs after the final totals line, so the remedy is the last thing shown. + reporter = config.pluginmanager.get_plugin("terminalreporter") + if _SETUP_FAILURES and reporter is not None: + reporter.write_sep("=", "loopx setup", yellow=True) + reporter.write_line( + f"{len(_SETUP_FAILURES)} failure(s) share one cause: {NPM_DEV_DEPENDENCIES_MISSING}." + ) diff --git a/tests/test_capability_code_maps.py b/tests/test_capability_code_maps.py new file mode 100644 index 0000000000..12bfd2e236 --- /dev/null +++ b/tests/test_capability_code_maps.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import re +from pathlib import Path + + +CAPABILITIES = Path(__file__).resolve().parents[1] / "loopx" / "capabilities" +HEADINGS = {"README.md": "## Code Map", "README.zh-CN.md": "## 代码地图"} + + +def _mapped_modules(readme: Path) -> set[str] | None: + text = readme.read_text(encoding="utf-8") + heading = HEADINGS[readme.name] + if heading not in text: + return None + section = text.split(heading, 1)[1].split("\n## ", 1)[0] + return set(re.findall(r"^\| `([^`]+)` \|", section, re.MULTILINE)) + + +def _package_modules(package: Path) -> set[str]: + modules = { + path.name + for path in package.iterdir() + if path.suffix in {".py", ".ts"} and path.name != "__init__.py" + } + modules |= { + f"{path.name}/" + for path in package.iterdir() + if path.is_dir() and (path / "__init__.py").is_file() + } + return modules + + +def test_code_maps_list_every_module_and_mirror_the_same_set() -> None: + mapped_packages = [] + for readme in sorted(CAPABILITIES.glob("*/README.md")): + mapped = _mapped_modules(readme) + if mapped is None: + continue + package = readme.parent + mapped_packages.append(package.name) + actual = _package_modules(package) + assert mapped == actual, ( + f"{package.name} code map is missing {sorted(actual - mapped)} " + f"and lists absent {sorted(mapped - actual)}" + ) + mirror = readme.with_name("README.zh-CN.md") + if mirror.is_file(): + assert _mapped_modules(mirror) == mapped, ( + f"{package.name} Chinese code map differs from the English one" + ) + assert "decision_context" in mapped_packages diff --git a/tests/test_contributing_guide.py b/tests/test_contributing_guide.py new file mode 100644 index 0000000000..291d3f84c1 --- /dev/null +++ b/tests/test_contributing_guide.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import json +import re +from pathlib import Path + +import yaml + +from loopx.control_plane.effect_runtime import MINIMUM_NODE_VERSION_TEXT + + +REPO_ROOT = Path(__file__).resolve().parents[1] +WORKFLOWS = REPO_ROOT / ".github" / "workflows" +CONTRIBUTING = REPO_ROOT / "CONTRIBUTING.md" + +# Every surface a user or contributor reads before the runtime check can fire. +NODE_REQUIREMENT_SURFACES = ( + "README.md", + "README.zh-CN.md", + "CONTRIBUTING.md", + "pyproject.toml", + "docs/guides/installing-loopx.md", + "loopx/entrypoint.py", +) + + +def _section(text: str, heading: str) -> str: + start = text.index(heading) + following = re.search(r"^#{2,3} ", text[start + len(heading):], re.MULTILINE) + end = start + len(heading) + following.start() if following else len(text) + return text[start:end] + + +def test_node_minimum_is_stated_once_everywhere() -> None: + for relative in NODE_REQUIREMENT_SURFACES: + text = (REPO_ROOT / relative).read_text(encoding="utf-8") + versions = set(re.findall(r"Node\.js (\d+\.\d+\.\d+)", text)) + assert versions == {MINIMUM_NODE_VERSION_TEXT}, ( + f"{relative} states Node.js {sorted(versions)}; " + f"the runtime minimum is {MINIMUM_NODE_VERSION_TEXT}" + ) + + package = json.loads((REPO_ROOT / "package.json").read_text(encoding="utf-8")) + assert package["engines"]["node"] == f">={MINIMUM_NODE_VERSION_TEXT}" + + for workflow in sorted(WORKFLOWS.glob("*.yml")): + pinned = set( + re.findall(r'node-version: "(\d+\.\d+\.\d+)"', workflow.read_text(encoding="utf-8")) + ) + assert pinned <= {MINIMUM_NODE_VERSION_TEXT}, ( + f"{workflow.name} pins Node.js {sorted(pinned)}; exact pins qualify the " + f"minimum {MINIMUM_NODE_VERSION_TEXT}, other lanes use a major version" + ) + + +def test_contributing_ci_table_lists_every_workflow() -> None: + section = _section( + CONTRIBUTING.read_text(encoding="utf-8"), "### What CI runs on a pull request" + ) + listed = set(re.findall(r"^\| `([^`]+\.yml)` \|", section, re.MULTILINE)) + present = {path.name for path in WORKFLOWS.glob("*.yml")} + assert listed == present, ( + f"CONTRIBUTING.md CI table is missing {sorted(present - listed)} " + f"and lists removed {sorted(listed - present)}" + ) + + +def test_contributing_names_every_merge_gate_dependency() -> None: + section = _section( + CONTRIBUTING.read_text(encoding="utf-8"), "### What CI runs on a pull request" + ) + workflow = yaml.safe_load((WORKFLOWS / "python-tests.yml").read_text(encoding="utf-8")) + needs = set(workflow["jobs"]["merge-gate"]["needs"]) - {"changes"} + missing = sorted(job for job in needs if f"`{job}`" not in section) + assert not missing, f"CONTRIBUTING.md does not name merge-gate jobs {missing}"