mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-12 23:02:45 +00:00
refactor(deps): rewrite drift check as tree-sitter 0.25 upgrade readiness monitor
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) <noreply@anthropic.com>
This commit is contained in:
parent
7b49df99ff
commit
cedef11890
4 changed files with 403 additions and 284 deletions
19
.github/dependabot.yml
vendored
19
.github/dependabot.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
243
.github/scripts/check-tree-sitter-drift.py
vendored
243
.github/scripts/check-tree-sitter-drift.py
vendored
|
|
@ -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 <version-that-emits-ABI-14>` 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())
|
||||
358
.github/scripts/check-tree-sitter-upgrade-readiness.py
vendored
Normal file
358
.github/scripts/check-tree-sitter-upgrade-readiness.py
vendored
Normal file
|
|
@ -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())
|
||||
|
|
@ -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` +
|
||||
'<sub>Generated by `.github/workflows/tree-sitter-drift-check.yml`. ' +
|
||||
'Re-runs weekly; closes automatically when the next clean run posts.</sub>';
|
||||
const title = 'Tree-sitter 0.25 upgrade readiness';
|
||||
const body = process.env.REPORT + '\n\n' +
|
||||
'<sub>Generated daily by `.github/workflows/tree-sitter-upgrade-readiness.yml`. ' +
|
||||
'Closes automatically when all blockers are resolved.</sub>';
|
||||
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,
|
||||
Loading…
Add table
Reference in a new issue