From cedef118900a07cb057128adb3e859356de6f533 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Thu, 16 Apr 2026 07:42:46 +0100 Subject: [PATCH] refactor(deps): rewrite drift check as tree-sitter 0.25 upgrade readiness monitor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the ABI drift pass/fail gate with a daily upgrade readiness dashboard that tracks peer-dep compatibility of all 14 grammars with tree-sitter@0.25.0 and reports which are ready, unreleased, or blocking. Key changes: - Rename drift-check → upgrade-readiness (script, workflow, job id) - Fix P0: pass report via env var, not ${{ }} template interpolation - Fix P1: npm fetch failure now adds a blocker instead of false-green - Fix P1: pass GITHUB_TOKEN for authenticated GitHub API calls - Switch Dependabot to daily for tree-sitter grammars - Use dict for blockers (no prefix collision), derive TARGET_RUNTIME constant, reuse GRAMMARS parser_path, normalize CRLF in comparisons - Reduce per-call HTTP timeout from 15s to 8s for workflow budget - PR runs warn on blockers instead of hard-failing Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/dependabot.yml | 19 +- .github/scripts/check-tree-sitter-drift.py | 243 ------------ .../check-tree-sitter-upgrade-readiness.py | 358 ++++++++++++++++++ ....yml => tree-sitter-upgrade-readiness.yml} | 67 ++-- 4 files changed, 403 insertions(+), 284 deletions(-) delete mode 100644 .github/scripts/check-tree-sitter-drift.py create mode 100644 .github/scripts/check-tree-sitter-upgrade-readiness.py rename .github/workflows/{tree-sitter-drift-check.yml => tree-sitter-upgrade-readiness.yml} (60%) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 21e8b48dc..f3530e4d3 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -15,15 +15,16 @@ updates: - dependencies - ci - # Gitnexus npm deps. Tree-sitter grammar bumps are grouped so we don't get - # one PR per grammar every time the ecosystem moves in lockstep. The - # tree-sitter RUNTIME is explicitly pinned because upgrading it changes - # which grammar ABIs load — upgrade deliberately, not automatically. See - # .github/scripts/check-tree-sitter-drift.py for the ABI invariant. + # Gitnexus npm deps — tree-sitter grammars checked daily so we catch + # new releases that unblock the tree-sitter 0.25 upgrade ASAP. Grammars + # are grouped so lockstep bumps produce a single PR. The tree-sitter + # RUNTIME is pinned — upgrade deliberately via the drift check workflow. + # See .github/scripts/check-tree-sitter-upgrade-readiness.py for + # the upgrade readiness tracker. - package-ecosystem: npm directory: /gitnexus schedule: - interval: weekly + interval: daily open-pull-requests-limit: 10 commit-message: prefix: chore(deps) @@ -38,10 +39,8 @@ updates: - tree-sitter - tree-sitter-cli ignore: - # Pin the tree-sitter runtime. Bumping 0.21 -> 0.22+ requires - # coordinated grammar updates and an ABI review (the vendored - # tree-sitter-proto is regenerated against a specific cli version). - # Do this by hand. + # Pin the tree-sitter runtime at 0.21.x until the drift check + # reports all grammars are peer-dep compatible with 0.25. - dependency-name: tree-sitter update-types: - version-update:semver-major diff --git a/.github/scripts/check-tree-sitter-drift.py b/.github/scripts/check-tree-sitter-drift.py deleted file mode 100644 index 9bbfc2347..000000000 --- a/.github/scripts/check-tree-sitter-drift.py +++ /dev/null @@ -1,243 +0,0 @@ -#!/usr/bin/env python3 -"""Detect tree-sitter ecosystem drift that Dependabot cannot see. - -Two invariants that Dependabot does not enforce: - - 1. ABI consistency. The tree-sitter runtime we pin (gitnexus/package.json - -> dependencies."tree-sitter") supports a known range of grammar ABIs. - Every grammar we load must ship a parser.c whose LANGUAGE_VERSION falls - inside that range. If a grammar bumps to an ABI the runtime cannot - load, the grammar silently fails at require() time -- fallback paths - kick in and test coverage can mask the degradation. - - 2. Vendored upstream drift. vendor/tree-sitter-proto/ is a snapshot of - coder3101/tree-sitter-proto's parser.c, regenerated against a specific - tree-sitter-cli version. Upstream keeps moving. If upstream ships a - grammar fix we care about, or cuts a new release, we want a human to - notice -- not for the vendored copy to silently rot. - -Invoked from .github/workflows/tree-sitter-drift-check.yml on a weekly -schedule. Runs locally too: - - python3 .github/scripts/check-tree-sitter-drift.py - -Outputs Markdown to stdout. Exit 0 when everything is in range and upstream -matches. Exit 1 when drift is detected (the workflow uses this to open or -update an issue). - -No external deps -- stdlib only, so it runs on any vanilla runner. -""" - -from __future__ import annotations - -import json -import pathlib -import re -import subprocess -import sys -import urllib.error -import urllib.request - -REPO_ROOT = pathlib.Path(__file__).resolve().parents[2] -GITNEXUS_DIR = REPO_ROOT / "gitnexus" -VENDOR_PROTO_DIR = GITNEXUS_DIR / "vendor" / "tree-sitter-proto" - -# Tree-sitter runtime -> (min_abi, max_abi) it can load. Extend when we bump -# the runtime and audit the new supported range from the runtime's release -# notes. Source of truth: tree-sitter/tree-sitter runtime release notes. -RUNTIME_ABI_RANGES: dict[str, tuple[int, int]] = { - "0.21": (13, 14), - "0.22": (13, 14), - "0.23": (13, 14), - "0.24": (13, 14), - "0.25": (13, 15), -} - -UPSTREAM_OWNER = "coder3101" -UPSTREAM_REPO = "tree-sitter-proto" -UPSTREAM_BRANCH = "main" - - -def read_runtime_minor() -> str: - """Return the tree-sitter runtime minor series we pin (e.g. '0.21').""" - pkg = json.loads((GITNEXUS_DIR / "package.json").read_text()) - raw = pkg["dependencies"]["tree-sitter"] - # Strip semver prefixes (^, ~, >=, etc.) and trailing qualifiers. - match = re.search(r"(\d+)\.(\d+)", raw) - if not match: - raise SystemExit(f"could not parse tree-sitter version: {raw!r}") - return f"{match.group(1)}.{match.group(2)}" - - -def extract_language_version(parser_c: pathlib.Path) -> int | None: - """Return the LANGUAGE_VERSION defined in a parser.c, or None if absent.""" - if not parser_c.is_file(): - return None - # Scan only the first few KB; LANGUAGE_VERSION lives near the top. - with parser_c.open("r", encoding="utf-8", errors="ignore") as fh: - head = fh.read(4096) - match = re.search(r"#define\s+LANGUAGE_VERSION\s+(\d+)", head) - return int(match.group(1)) if match else None - - -def installed_grammars() -> list[pathlib.Path]: - """Return parser.c paths for every installed tree-sitter-* grammar.""" - nm = GITNEXUS_DIR / "node_modules" - if not nm.is_dir(): - return [] - out: list[pathlib.Path] = [] - for entry in sorted(nm.iterdir()): - if not entry.name.startswith("tree-sitter-"): - continue - # Skip the runtime itself and the CLI; they have no parser.c. - if entry.name in ("tree-sitter-cli",): - continue - parser_c = entry / "src" / "parser.c" - if parser_c.is_file(): - out.append(parser_c) - return out - - -def fetch_upstream_parser_c() -> str | None: - """Fetch coder3101/tree-sitter-proto's current parser.c as text.""" - url = ( - f"https://raw.githubusercontent.com/{UPSTREAM_OWNER}/" - f"{UPSTREAM_REPO}/{UPSTREAM_BRANCH}/src/parser.c" - ) - try: - with urllib.request.urlopen(url, timeout=20) as resp: - return resp.read().decode("utf-8", errors="ignore") - except (urllib.error.URLError, urllib.error.HTTPError) as exc: - print(f"WARN: could not fetch upstream parser.c: {exc}", file=sys.stderr) - return None - - -def fetch_upstream_head_sha() -> str | None: - """Return the short SHA of coder3101/tree-sitter-proto HEAD on main.""" - url = ( - f"https://api.github.com/repos/{UPSTREAM_OWNER}/" - f"{UPSTREAM_REPO}/commits/{UPSTREAM_BRANCH}" - ) - try: - with urllib.request.urlopen(url, timeout=20) as resp: - data = json.loads(resp.read().decode("utf-8")) - return data.get("sha", "")[:12] or None - except (urllib.error.URLError, urllib.error.HTTPError) as exc: - print(f"WARN: could not fetch upstream HEAD: {exc}", file=sys.stderr) - return None - - -def md_h(text: str, level: int = 2) -> str: - return f"{'#' * level} {text}\n" - - -def main() -> int: - drift_found = False - lines: list[str] = [] - lines.append(md_h("Tree-sitter ecosystem drift report", 1)) - lines.append("") - - # --- ABI consistency -------------------------------------------------- - runtime_minor = read_runtime_minor() - lines.append(md_h(f"Runtime: tree-sitter {runtime_minor}.x", 2)) - if runtime_minor not in RUNTIME_ABI_RANGES: - lines.append( - f"- UNKNOWN runtime ABI range for `{runtime_minor}`. " - "Add it to `RUNTIME_ABI_RANGES` in this script after auditing " - "the upstream release notes.\n" - ) - drift_found = True - abi_min, abi_max = (0, 0) - else: - abi_min, abi_max = RUNTIME_ABI_RANGES[runtime_minor] - lines.append(f"- Supported grammar ABI range: **{abi_min}..{abi_max}**\n") - - grammars = installed_grammars() - if not grammars: - lines.append( - "- No installed grammars found under `gitnexus/node_modules/`. " - "Run `npm install` in `gitnexus/` before checking ABI consistency.\n" - ) - else: - lines.append(md_h("Installed grammar ABIs", 3)) - lines.append("| Grammar | ABI | In range? |") - lines.append("|---|---|---|") - for parser_c in grammars: - name = parser_c.parents[1].name - abi = extract_language_version(parser_c) - if abi is None: - lines.append(f"| `{name}` | (no LANGUAGE_VERSION found) | [warn] |") - drift_found = True - continue - in_range = abi_min <= abi <= abi_max - lines.append( - f"| `{name}` | {abi} | {'[ok]' if in_range else '[FAIL] OUT OF RANGE'} |" - ) - if not in_range: - drift_found = True - - # Vendored proto must also be in range. - vendored_abi = extract_language_version(VENDOR_PROTO_DIR / "src" / "parser.c") - lines.append("") - lines.append(md_h("Vendored tree-sitter-proto", 3)) - if vendored_abi is None: - lines.append("- [warn] Could not read vendored parser.c LANGUAGE_VERSION.\n") - drift_found = True - else: - in_range = abi_min <= vendored_abi <= abi_max - lines.append( - f"- Vendored ABI: **{vendored_abi}** -- " - f"{'in range [ok]' if in_range else '**OUT OF RANGE** [FAIL]'}" - ) - if not in_range: - drift_found = True - - # --- Upstream drift --------------------------------------------------- - lines.append("") - lines.append(md_h("Upstream drift check", 2)) - upstream_parser_c = fetch_upstream_parser_c() - if upstream_parser_c is None: - lines.append("- [warn] Could not fetch upstream parser.c; skipping drift check.\n") - else: - upstream_abi_match = re.search( - r"#define\s+LANGUAGE_VERSION\s+(\d+)", upstream_parser_c[:4096] - ) - upstream_abi = int(upstream_abi_match.group(1)) if upstream_abi_match else None - upstream_size = len(upstream_parser_c) - local_path = VENDOR_PROTO_DIR / "src" / "parser.c" - local_parser_c = local_path.read_text(encoding="utf-8", errors="ignore") if local_path.is_file() else "" - local_size = len(local_parser_c) - size_match = local_size == upstream_size and local_parser_c == upstream_parser_c - upstream_sha = fetch_upstream_head_sha() or "?" - - lines.append(f"- Upstream: `{UPSTREAM_OWNER}/{UPSTREAM_REPO}@{UPSTREAM_BRANCH}` (HEAD `{upstream_sha}`)") - lines.append(f"- Upstream ABI: **{upstream_abi}**") - lines.append(f"- Vendored ABI: **{vendored_abi}**") - lines.append( - f"- parser.c identical to upstream? " - f"{'yes [ok]' if size_match else 'no -- upstream has moved'}" - ) - if not size_match: - lines.append("") - lines.append( - "**Action:** review the upstream changes. If the vendored ABI " - "is still compatible with our runtime AND the change is worth " - "picking up, regenerate `vendor/tree-sitter-proto/src/parser.c` " - "via `tree-sitter-cli ` against " - f"upstream `{upstream_sha}` and update the description in " - "`vendor/tree-sitter-proto/package.json`. If upstream's ABI is " - "now outside our runtime range, document the skip and wait for " - "the tree-sitter runtime upgrade." - ) - drift_found = True - - lines.append("") - lines.append(md_h("Result", 2)) - lines.append(f"- Drift detected: **{'yes' if drift_found else 'no'}**") - - print("\n".join(lines)) - return 1 if drift_found else 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.github/scripts/check-tree-sitter-upgrade-readiness.py b/.github/scripts/check-tree-sitter-upgrade-readiness.py new file mode 100644 index 000000000..df21533a8 --- /dev/null +++ b/.github/scripts/check-tree-sitter-upgrade-readiness.py @@ -0,0 +1,358 @@ +#!/usr/bin/env python3 +"""Monitor tree-sitter 0.25 upgrade readiness. + +Tracks two things Dependabot cannot see: + + 1. Peer-dep compatibility. Each tree-sitter-* grammar declares a peer + dependency on the tree-sitter runtime. We want to know when every + grammar's *latest npm release* satisfies tree-sitter@0.25.0 so we + can upgrade without --legacy-peer-deps. + + 2. Vendored upstream drift. vendor/tree-sitter-proto/ is a snapshot of + coder3101/tree-sitter-proto's parser.c. When upstream moves, we want + to know whether we can pick it up. + +Invoked from .github/workflows/tree-sitter-upgrade-readiness.yml daily. +Runs locally too: + + python3 .github/scripts/check-tree-sitter-upgrade-readiness.py + +Outputs Markdown to stdout. Exit 0 when every grammar is upgrade-ready +and the vendored proto is in sync. Exit 1 when blockers remain (the +workflow uses this to open or update a tracking issue). + +No external deps -- stdlib only, so it runs on any vanilla runner. +""" + +from __future__ import annotations + +import json +import os +import pathlib +import re +import sys +import urllib.error +import urllib.request + +REPO_ROOT = pathlib.Path(__file__).resolve().parents[2] +GITNEXUS_DIR = REPO_ROOT / "gitnexus" +VENDOR_PROTO_DIR = GITNEXUS_DIR / "vendor" / "tree-sitter-proto" + +# ── Upgrade target ────────────────────────────────────────────────────── +# The runtime version we want to upgrade TO. Update this when the goal +# changes (e.g. once 0.25 lands and we target 0.26). +TARGET_RUNTIME = "0.25.0" +TARGET_RUNTIME_MAJOR_MINOR = ".".join(TARGET_RUNTIME.split(".")[:2]) + +# Tree-sitter runtime -> (min_abi, max_abi) it can load. Only the current +# and target entries matter; extend when changing TARGET_RUNTIME. +RUNTIME_ABI_RANGES: dict[str, tuple[int, int]] = { + "0.21": (13, 14), + "0.25": (13, 15), +} + +assert TARGET_RUNTIME_MAJOR_MINOR in RUNTIME_ABI_RANGES, ( + f"RUNTIME_ABI_RANGES has no entry for {TARGET_RUNTIME_MAJOR_MINOR!r}. " + f"Add the ABI range after auditing the upstream release notes." +) + +# Grammars we use. Values are the upstream GitHub repos to check for +# unreleased ABI bumps (owner/repo, branch, parser.c path). +GRAMMARS: dict[str, tuple[str, str, str]] = { + "tree-sitter-c": ("tree-sitter/tree-sitter-c", "master", "src/parser.c"), + "tree-sitter-c-sharp": ("tree-sitter/tree-sitter-c-sharp", "master", "src/parser.c"), + "tree-sitter-cpp": ("tree-sitter/tree-sitter-cpp", "master", "src/parser.c"), + "tree-sitter-dart": ("UserNobody14/tree-sitter-dart", "master", "src/parser.c"), + "tree-sitter-go": ("tree-sitter/tree-sitter-go", "master", "src/parser.c"), + "tree-sitter-java": ("tree-sitter/tree-sitter-java", "master", "src/parser.c"), + "tree-sitter-javascript": ("tree-sitter/tree-sitter-javascript", "master", "src/parser.c"), + "tree-sitter-kotlin": ("fwcd/tree-sitter-kotlin", "main", "src/parser.c"), + "tree-sitter-php": ("tree-sitter/tree-sitter-php", "master", "php/src/parser.c"), + "tree-sitter-python": ("tree-sitter/tree-sitter-python", "master", "src/parser.c"), + "tree-sitter-ruby": ("tree-sitter/tree-sitter-ruby", "master", "src/parser.c"), + "tree-sitter-rust": ("tree-sitter/tree-sitter-rust", "master", "src/parser.c"), + "tree-sitter-swift": ("alex-pinkus/tree-sitter-swift", "main", "src/parser.c"), + "tree-sitter-typescript": ("tree-sitter/tree-sitter-typescript", "master", "typescript/src/parser.c"), +} + +UPSTREAM_PROTO_OWNER = "coder3101" +UPSTREAM_PROTO_REPO = "tree-sitter-proto" +UPSTREAM_PROTO_BRANCH = "main" + + +# ── Helpers ───────────────────────────────────────────────────────────── + +def read_current_runtime() -> str: + """Return the tree-sitter runtime version pinned in package.json (e.g. '0.21').""" + pkg = json.loads((GITNEXUS_DIR / "package.json").read_text()) + raw = pkg["dependencies"]["tree-sitter"] + match = re.search(r"(\d+)\.(\d+)", raw) + if not match: + raise SystemExit(f"could not parse tree-sitter version: {raw!r}") + return f"{match.group(1)}.{match.group(2)}" + + +def npm_view_json(pkg: str) -> dict | None: + """Fetch package metadata from the npm registry via HTTPS. + + Uses the registry API directly so we don't depend on the npm CLI + being available (it's a batch file on Windows which complicates + subprocess calls). + """ + url = f"https://registry.npmjs.org/{pkg}/latest" + try: + req = urllib.request.Request(url, headers={"Accept": "application/json"}) + with urllib.request.urlopen(req, timeout=8) as resp: + return json.loads(resp.read().decode("utf-8")) + except (urllib.error.URLError, urllib.error.HTTPError, json.JSONDecodeError): + return None + + +def satisfies_target(peer_range: str | None, target: str) -> bool: + """Check if a semver range like '^0.22.4' or '^0.25.0' satisfies the target. + + Simple heuristic: extract the minimum version from the range and check + if target >= min. For caret ranges (^X.Y.Z), the upper bound is the + next major (for X>0) or next minor (for X==0). We check both bounds. + """ + if peer_range is None: + # No peer dep declared = no constraint = compatible. + return True + match = re.search(r"(\d+)\.(\d+)\.(\d+)", peer_range) + if not match: + return False + min_major, min_minor, min_patch = int(match.group(1)), int(match.group(2)), int(match.group(3)) + + t_match = re.search(r"(\d+)\.(\d+)\.(\d+)", target) + if not t_match: + return False + t_major, t_minor, t_patch = int(t_match.group(1)), int(t_match.group(2)), int(t_match.group(3)) + + # Target must be >= minimum. + target_tuple = (t_major, t_minor, t_patch) + min_tuple = (min_major, min_minor, min_patch) + if target_tuple < min_tuple: + return False + + # For caret ranges with major 0: ^0.X.Y allows [0.X.Y, 0.(X+1).0). + if peer_range.startswith("^") and min_major == 0: + if t_major != 0 or t_minor >= min_minor + 1: + return False + # For caret ranges with major >0: ^X.Y.Z allows [X.Y.Z, (X+1).0.0). + elif peer_range.startswith("^") and min_major > 0: + if t_major >= min_major + 1: + return False + + return True + + +_GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN") + + +def fetch_text(url: str, timeout: int = 8) -> str | None: + """Fetch a URL and return its text, or None on failure. + + Adds an Authorization header for github.com URLs when GITHUB_TOKEN is + set (raises the rate limit from 60 to 5 000 requests/hour). + """ + headers: dict[str, str] = {} + if _GITHUB_TOKEN and ("github.com" in url or "githubusercontent.com" in url): + headers["Authorization"] = f"Bearer {_GITHUB_TOKEN}" + try: + req = urllib.request.Request(url, headers=headers) + with urllib.request.urlopen(req, timeout=timeout) as resp: + return resp.read().decode("utf-8", errors="ignore") + except (urllib.error.URLError, urllib.error.HTTPError): + return None + + +def extract_abi_from_text(text: str) -> int | None: + """Extract LANGUAGE_VERSION from parser.c text.""" + match = re.search(r"#define\s+LANGUAGE_VERSION\s+(\d+)", text[:4096]) + return int(match.group(1)) if match else None + + +def extract_language_version(parser_c: pathlib.Path) -> int | None: + """Return the LANGUAGE_VERSION defined in a parser.c, or None if absent.""" + if not parser_c.is_file(): + return None + with parser_c.open("r", encoding="utf-8", errors="ignore") as fh: + head = fh.read(4096) + return extract_abi_from_text(head) + + +def md_h(text: str, level: int = 2) -> str: + return f"{'#' * level} {text}\n" + + +# ── Main ──────────────────────────────────────────────────────────────── + +def main() -> int: + blockers: dict[str, str] = {} + lines: list[str] = [] + lines.append(md_h("Tree-sitter 0.25 upgrade readiness", 1)) + lines.append("") + + current_runtime = read_current_runtime() + current_abi_range = RUNTIME_ABI_RANGES.get(current_runtime, (0, 0)) + target_abi_range = RUNTIME_ABI_RANGES.get(TARGET_RUNTIME_MAJOR_MINOR, (0, 0)) + + lines.append(f"- Current runtime: `tree-sitter@{current_runtime}.x` (ABI {current_abi_range[0]}..{current_abi_range[1]})") + lines.append(f"- Target runtime: `tree-sitter@{TARGET_RUNTIME}` (ABI {target_abi_range[0]}..{target_abi_range[1]})") + lines.append("") + + # ── Grammar peer-dep compatibility ─────────────────────────────── + lines.append(md_h("Grammar compatibility", 2)) + lines.append("| Grammar | npm latest | Peer dep | Satisfies 0.25? | ABI | Upstream ABI | Status |") + lines.append("|---|---|---|---|---|---|---|") + + ready_count = 0 + total_count = len(GRAMMARS) + + for name, (upstream_repo, upstream_branch, parser_path) in sorted(GRAMMARS.items()): + # Fetch latest npm metadata. + info = npm_view_json(name) + fetch_failed = info is None + npm_version = "?" + peer_range = None + peer_optional = True + if info: + npm_version = info.get("version", "?") + peers = info.get("peerDependencies") or {} + peer_range = peers.get("tree-sitter") + meta = info.get("peerDependenciesMeta") or {} + ts_meta = meta.get("tree-sitter") or {} + peer_optional = ts_meta.get("optional", False) if peer_range else True + + if fetch_failed: + peer_display = "? (fetch failed)" + compatible = False + else: + peer_display = peer_range or "none" + if peer_range and not peer_optional: + peer_display += " (required)" + compatible = satisfies_target(peer_range, TARGET_RUNTIME) + + # Check installed ABI using the same parser_path from GRAMMARS. + installed_parser = GITNEXUS_DIR / "node_modules" / name / parser_path + if not installed_parser.is_file(): + # Fallback to default location. + installed_parser = GITNEXUS_DIR / "node_modules" / name / "src" / "parser.c" + installed_abi = extract_language_version(installed_parser) + abi_display = str(installed_abi) if installed_abi else "?" + + # Check upstream (main/master branch) ABI for unreleased work. + upstream_url = ( + f"https://raw.githubusercontent.com/{upstream_repo}/" + f"{upstream_branch}/{parser_path}" + ) + upstream_text = fetch_text(upstream_url) + upstream_abi = extract_abi_from_text(upstream_text) if upstream_text else None + upstream_abi_display = str(upstream_abi) if upstream_abi else "?" + + # Determine status. + if fetch_failed: + status = "Unknown (fetch failed)" + blockers[name] = f"`{name}`: npm registry fetch failed — could not verify peer dep" + elif compatible: + status = "Ready" + ready_count += 1 + elif upstream_abi and upstream_abi >= 15: + status = "Unreleased (ABI 15 on main)" + blockers[name] = f"`{name}`: ABI 15 on `{upstream_repo}` main but not published to npm" + else: + status = "Blocking" + blockers[name] = f"`{name}@{npm_version}`: peer `{peer_display}` incompatible with 0.25" + + # Also check upstream package.json for relaxed peer dep. + if not compatible and not fetch_failed: + upstream_pkg_url = ( + f"https://raw.githubusercontent.com/{upstream_repo}/" + f"{upstream_branch}/package.json" + ) + upstream_pkg_text = fetch_text(upstream_pkg_url) + if upstream_pkg_text: + try: + upstream_pkg = json.loads(upstream_pkg_text) + upstream_peer = (upstream_pkg.get("peerDependencies") or {}).get("tree-sitter") + if upstream_peer and satisfies_target(upstream_peer, TARGET_RUNTIME): + status = "Unreleased (peer relaxed on main)" + blockers[name] = f"`{name}`: peer dep relaxed on `{upstream_repo}` main but not published to npm" + except json.JSONDecodeError: + pass + + compat_icon = "Yes" if compatible else "**No**" + lines.append( + f"| `{name}` | {npm_version} | {peer_display} | {compat_icon} | {abi_display} | {upstream_abi_display} | {status} |" + ) + + lines.append("") + lines.append(f"**{ready_count}/{total_count}** grammars ready for `tree-sitter@{TARGET_RUNTIME}`.") + lines.append("") + + # ── Vendored proto drift ───────────────────────────────────────── + lines.append(md_h("Vendored tree-sitter-proto", 2)) + vendored_abi = extract_language_version(VENDOR_PROTO_DIR / "src" / "parser.c") + + upstream_proto_url = ( + f"https://raw.githubusercontent.com/{UPSTREAM_PROTO_OWNER}/" + f"{UPSTREAM_PROTO_REPO}/{UPSTREAM_PROTO_BRANCH}/src/parser.c" + ) + upstream_proto_text = fetch_text(upstream_proto_url) + upstream_proto_abi = extract_abi_from_text(upstream_proto_text) if upstream_proto_text else None + + sha_url = ( + f"https://api.github.com/repos/{UPSTREAM_PROTO_OWNER}/" + f"{UPSTREAM_PROTO_REPO}/commits/{UPSTREAM_PROTO_BRANCH}" + ) + sha_text = fetch_text(sha_url) + upstream_sha = "?" + if sha_text: + try: + upstream_sha = json.loads(sha_text).get("sha", "?")[:12] + except json.JSONDecodeError: + pass + + local_proto_path = VENDOR_PROTO_DIR / "src" / "parser.c" + local_proto_text = local_proto_path.read_text(encoding="utf-8", errors="ignore") if local_proto_path.is_file() else "" + in_sync = bool( + upstream_proto_text + and local_proto_text.replace("\r\n", "\n") + == upstream_proto_text.replace("\r\n", "\n") + ) + + lines.append(f"- Upstream: `{UPSTREAM_PROTO_OWNER}/{UPSTREAM_PROTO_REPO}@{UPSTREAM_PROTO_BRANCH}` (HEAD `{upstream_sha}`)") + lines.append(f"- Upstream ABI: **{upstream_proto_abi}**") + lines.append(f"- Vendored ABI: **{vendored_abi}**") + lines.append(f"- In sync: {'yes' if in_sync else 'no — upstream has diverged'}") + + if upstream_proto_abi and vendored_abi and upstream_proto_abi > vendored_abi: + can_upgrade = upstream_proto_abi <= target_abi_range[1] + lines.append(f"- Upstream ABI {upstream_proto_abi} {'is' if can_upgrade else 'is NOT'} within target runtime range ({target_abi_range[0]}..{target_abi_range[1]})") + if can_upgrade: + lines.append(f"- **Action:** after upgrading to tree-sitter@{TARGET_RUNTIME}, regenerate vendored parser.c from upstream `{upstream_sha}`") + else: + lines.append(f"- **Action:** wait for runtime upgrade beyond {TARGET_RUNTIME} that supports ABI {upstream_proto_abi}") + blockers["vendored-proto-abi"] = f"vendored tree-sitter-proto: upstream ABI {upstream_proto_abi} outside target range" + elif not in_sync: + lines.append("- **Action:** review upstream changes; vendored copy may need updating") + blockers["vendored-proto-sync"] = "vendored tree-sitter-proto: out of sync with upstream" + + # ── Summary ────────────────────────────────────────────────────── + lines.append("") + lines.append(md_h("Summary", 2)) + if blockers: + lines.append(f"**{len(blockers)} blocker(s) remaining:**\n") + for b in blockers.values(): + lines.append(f"- {b}") + lines.append("") + lines.append("Upgrade to `tree-sitter@0.25` is **blocked**.") + else: + lines.append("All grammars are compatible. Upgrade to `tree-sitter@0.25` is **ready**.") + + print("\n".join(lines)) + return 1 if blockers else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/tree-sitter-drift-check.yml b/.github/workflows/tree-sitter-upgrade-readiness.yml similarity index 60% rename from .github/workflows/tree-sitter-drift-check.yml rename to .github/workflows/tree-sitter-upgrade-readiness.yml index 03bf40a4a..3d17e100d 100644 --- a/.github/workflows/tree-sitter-drift-check.yml +++ b/.github/workflows/tree-sitter-upgrade-readiness.yml @@ -1,24 +1,24 @@ -name: Tree-sitter Drift Check +name: Tree-sitter Upgrade Readiness -# Catches drift Dependabot cannot see: -# 1. ABI-range consistency between the tree-sitter runtime and every -# installed grammar (including the vendored tree-sitter-proto). -# 2. Upstream divergence between vendor/tree-sitter-proto/src/parser.c -# and coder3101/tree-sitter-proto main. -# See .github/scripts/check-tree-sitter-drift.py for the invariants. +# Monitors readiness for upgrading tree-sitter to 0.25.x. Tracks: +# 1. Peer-dep compatibility — can each grammar install cleanly with +# tree-sitter@0.25.0 without --legacy-peer-deps? +# 2. Vendored proto drift — has coder3101/tree-sitter-proto moved +# ahead of our vendored snapshot? +# See .github/scripts/check-tree-sitter-upgrade-readiness.py for the logic. # # Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention". on: schedule: - # Mondays at 09:00 UTC. Weekly to match Dependabot's cadence so any - # drift shows up alongside the week's dep PRs. - - cron: '0 9 * * 1' + # Daily at 09:00 UTC. Matches Dependabot's daily cadence so drift + # and dep PRs surface together. + - cron: '0 9 * * *' workflow_dispatch: pull_request: paths: - - '.github/scripts/check-tree-sitter-drift.py' - - '.github/workflows/tree-sitter-drift-check.yml' + - '.github/scripts/check-tree-sitter-upgrade-readiness.py' + - '.github/workflows/tree-sitter-upgrade-readiness.yml' concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -28,8 +28,8 @@ permissions: contents: read jobs: - drift: - name: Report tree-sitter drift + readiness: + name: Check upgrade readiness runs-on: ubuntu-latest timeout-minutes: 10 permissions: @@ -43,12 +43,14 @@ jobs: with: build: 'false' - - name: Run drift check - id: drift + - name: Run upgrade readiness check + id: readiness shell: bash + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set +e - python3 .github/scripts/check-tree-sitter-drift.py > drift-report.md + python3 .github/scripts/check-tree-sitter-upgrade-readiness.py > drift-report.md code=$? set -e echo "exit_code=$code" >> "$GITHUB_OUTPUT" @@ -60,23 +62,26 @@ jobs: echo "=== Report ===" cat drift-report.md - - name: Fail PR-triggered runs on drift - if: github.event_name == 'pull_request' && steps.drift.outputs.exit_code != '0' + # On PR runs, the script validates that it runs correctly. Blockers + # are informational — the scheduled run opens a tracking issue. + - name: Annotate PR with readiness status + if: github.event_name == 'pull_request' && steps.readiness.outputs.exit_code != '0' run: | - echo "::error::Tree-sitter drift detected. See job output." - exit 1 + echo "::warning::Tree-sitter 0.25 upgrade has blockers. See job output for the full readiness report." - name: Upsert tracking issue on scheduled runs if: > github.event_name == 'schedule' && - steps.drift.outputs.exit_code != '0' - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + steps.readiness.outputs.exit_code != '0' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + REPORT: ${{ steps.readiness.outputs.report }} with: script: | - const title = 'Tree-sitter ecosystem drift detected'; - const body = `${{ steps.drift.outputs.report }}\n\n` + - 'Generated by `.github/workflows/tree-sitter-drift-check.yml`. ' + - 'Re-runs weekly; closes automatically when the next clean run posts.'; + const title = 'Tree-sitter 0.25 upgrade readiness'; + const body = process.env.REPORT + '\n\n' + + 'Generated daily by `.github/workflows/tree-sitter-upgrade-readiness.yml`. ' + + 'Closes automatically when all blockers are resolved.'; const { data: open } = await github.rest.issues.listForRepo({ owner: context.repo.owner, repo: context.repo.repo, @@ -107,11 +112,11 @@ jobs: - name: Close tracking issue on clean scheduled runs if: > github.event_name == 'schedule' && - steps.drift.outputs.exit_code == '0' - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + steps.readiness.outputs.exit_code == '0' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | - const title = 'Tree-sitter ecosystem drift detected'; + const title = 'Tree-sitter 0.25 upgrade readiness'; const { data: open } = await github.rest.issues.listForRepo({ owner: context.repo.owner, repo: context.repo.repo, @@ -125,7 +130,7 @@ jobs: owner: context.repo.owner, repo: context.repo.repo, issue_number: existing.number, - body: 'Latest scheduled drift check came back clean. Closing automatically.', + body: 'All grammars are now compatible with tree-sitter@0.25. Upgrade is ready! Closing automatically.', }); await github.rest.issues.update({ owner: context.repo.owner,