mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-10 03:27:59 +00:00
* fix(lang-kotlin): support `fun interface` extraction via tree-sitter-kotlin re-vendor Vendored tree-sitter-kotlin@0.3.8 (fwcd) parsed `fun interface Foo` as an ERROR node and dropped the declaration plus its abstract method, so functional (SAM) interfaces were never extracted. The fix landed upstream in fwcd/tree-sitter-kotlin#169 (closes #87), merged to main 2025-04-25, but is not in any npm release (latest tag 0.3.8; main is the unreleased 0.4.0). Re-vendor the grammar from the unreleased fwcd main commit c8ac3d26: - refresh src/{parser.c,scanner.c,node-types.json,tree_sitter/*.h} and bindings/node/index.js; bump the vendor version 0.3.8 -> 0.4.0; record the pinned SHA + rationale in _vendoredBy and the vendor README. - switch the prebuild workflow's kotlin registry kind 'npm' -> 'vendored' (the fix is unreleased on npm, so prebuilds must build from the vendored C source, like swift/dart/proto). - add a hold to .github/vendored-grammars.json so the weekly auto-update monitor does not strict-inequality-revert the pin to the broken npm 0.3.8 (isNewer compares 0.3.8 != 0.4.0). - add 3 regression tests + a fixture asserting fun interfaces extract as Interface nodes with their abstract methods, and that plain-interface heritage still resolves. Existing KOTLIN_QUERIES need no change: the new grammar models `fun interface` as a class_declaration with an "interface" keyword child (plus an extra "fun" modifier child), which the existing interface rule already matches. Full Kotlin suite green against the new grammar (300 unit/cfg/resolver + 233 integration). NOTE: prebuilds/ are intentionally not in this commit. The version bump auto-triggers .github/workflows/build-tree-sitter-prebuilds.yml, which regenerates all 6 platform binaries from the vendored source in a separate PR. Until that lands, CI loads the committed 0.3.8 prebuild, so the new kotlin tests are red and the grammar change is inert at runtime. Merge the prebuild PR first or together. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(ci): count kotlin's vendored hold as a 0.25-readiness blocker The kotlin `hold` added in the previous commit makes the tree-sitter upgrade-readiness report count it as a blocker — the report treats every held vendored grammar as frozen below a runtime upgrade (same as the intentionally-pinned tree-sitter-cpp and the ABI-held tree-sitter-c), "in-range ABI or not". So the report's blocker count goes 2 -> 3. Update the hardcoded count in test_issue_update_summary_regex_matches_current_report (and the _render_report docstring) accordingly — exactly as that test instructs: "if a grammar is added/removed or a pin/hold changes, update the expected counts". kotlin's ABI (14) is in range; the hold is what flags it, with the reason recorded in .github/vendored-grammars.json. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(ci): refresh kotlin baselines for the grammar bump Two committed baselines pinned the pre-bump kotlin state and broke when the grammar was re-vendored (0.3.8 -> 0.4.0): - cli-commands.test.ts pinned the vendored kotlin package version at 0.3.8 -> update to 0.4.0. - bench/scope-capture/baselines.json: the new kotlin-fun-interface fixture joins the lang-resolution/kotlin-* corpus AND the new grammar parses `fun interface` as a class_declaration (not an ERROR node), so the capture fingerprint drifts. Rebaselined to the NEW grammar's fingerprint (verified by building the vendored parser.c against tree-sitter@0.21.1 and running measure.mjs --check); scaling ~0.83 (linear). Like the fun-interface integration tests, the scope-capture --check passes only once the regenerated prebuilds land; until then CI loads the committed 0.3.8 binary, so it stays red. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci(prebuilds): rebuild + commit grammar prebuilds into the PR on vendored-source change build-tree-sitter-prebuilds.yml previously rebuilt a grammar's native prebuilds only when its package.json VERSION bumped, and delivered them via a separate bot PR. Now any change to the vendored grammar source re-cuts the prebuilds and they ride into the same PR. - Trigger on any build-affecting change under gitnexus/vendor/tree-sitter-*/** (parser.c, grammar.js, binding.gyp, scanner, bindings), not just version bumps. The prebuilds/ subtree is negated in the paths filter AND excluded from the guard's source diff, so the bot's own prebuild commit can never retrigger the workflow (no build -> commit -> build loop). - The guard builds a grammar when its recorded version changed OR its vendored source changed vs the PR base. - Same-repo PRs get the rebuilt prebuilds committed straight onto their own head branch (included in the SAME PR) via a non-force push that only adds a commit on top of head. Manual dispatch still opens a fresh chore/ PR; fork PRs stay artifacts-only (a bot cannot push into a fork branch). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci(prebuilds): deliver rebuilt prebuilds to fork PRs via a trusted workflow_run stage A fork PR's producer run has a read-only token and no secrets, so it can build and validate the prebuilds but can't commit them. Add the safe two-stage handoff that mirrors the pr-autofix producer/publish split. - build-tree-sitter-prebuilds.yml (untrusted producer): on a fork PR, upload a pr-meta artifact (schema, pr_number, head_sha, head_ref, head_repo, base_repo) alongside the prebuild artifacts. Values flow through env + jq, never interpolated into a shell. - commit-fork-prebuilds.yml (trusted, workflow_run): downloads ONLY the artifacts (never executes fork code — it checks out the pinned HEAD SHA solely to add files), allowlist-validates every metadata field, cross-checks identity against the workflow_run authority (head_sha / head_repo / pr_number, via commits/{sha}/pulls for forks), then pushes the prebuilds onto the fork head branch with --force-with-lease + http.extraheader auth. No PAT: this works when the contributor left "Allow edits by maintainers" on; on push failure it posts a sticky comment telling them to enable it or commit the downloaded artifacts. zizmor: allowlist commit-fork-prebuilds.yml's workflow_run dangerous-trigger with the documented mitigation, matching the existing ci-report / pr-autofix entries. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(vendor): rebuild tree-sitter-kotlin prebuilds for the re-vendored fun-interface grammar The fun-interface re-vendor changed vendor/tree-sitter-kotlin source but left main's old (0.3.8) prebuilds in place, so all 6 platform binaries were stale relative to the new parser. Replace them with the freshly cross-built + ABI-validated binaries from build-tree-sitter-prebuilds run 28010841458 — each .node was require()-loaded and parsed a snippet on its target platform-arch before upload. This is the manual equivalent of the commit-fork-prebuilds.yml delivery, which can't run for this fork PR until it lands on main. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(lang-kotlin): read extension-function receiverType from the re-vendored grammar's `receiver` field The fun-interface re-vendor changed the kotlin AST: an extension function's receiver is now a `receiver_type` exposed via a named `receiver` field, where the old grammar emitted a bare user_type before the name. extractReceiverType only matched the old shape, so receiverType came back null (method-extraction.test.ts > Kotlin MethodExtractor > extracts receiverType). Prefer the `receiver` field (unwrapping it), and keep the old child-scan — now also recognizing `receiver_type` — as a fallback. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
477 lines
23 KiB
Python
477 lines
23 KiB
Python
#!/usr/bin/env python3
|
||
"""Tests for check-tree-sitter-upgrade-readiness.py.
|
||
|
||
Stdlib-only (``unittest`` + ``unittest.mock``) to match the script under test,
|
||
which is deliberately dependency-free so it runs on any vanilla runner. Run with:
|
||
|
||
python3 -m unittest .github/scripts/test_check_tree_sitter_upgrade_readiness.py
|
||
|
||
(pytest also discovers ``unittest.TestCase`` classes, so a future pytest CI job
|
||
picks these up unchanged.)
|
||
|
||
These tests lock in the #858 fix: the 5 vendored grammars
|
||
(c/swift/kotlin/dart/proto) are classified from the shared manifest
|
||
(.github/vendored-grammars.json), their ABI is read from gitnexus/vendor/<name>,
|
||
and the report never renders a bare ``?`` placeholder. All network is mocked.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import contextlib
|
||
import http.client
|
||
import importlib.util
|
||
import io
|
||
import json
|
||
import pathlib
|
||
import re
|
||
from unittest import TestCase, main, mock
|
||
|
||
# ── Load the hyphenated script as a module ───────────────────────────────
|
||
_SCRIPTS_DIR = pathlib.Path(__file__).resolve().parent
|
||
_SCRIPT = _SCRIPTS_DIR / "check-tree-sitter-upgrade-readiness.py"
|
||
_REPO_ROOT = _SCRIPTS_DIR.parents[1]
|
||
_MANIFEST = _REPO_ROOT / ".github" / "vendored-grammars.json"
|
||
|
||
_spec = importlib.util.spec_from_file_location("readiness_under_test", _SCRIPT)
|
||
readiness = importlib.util.module_from_spec(_spec)
|
||
_spec.loader.exec_module(readiness) # type: ignore[union-attr]
|
||
|
||
# The exact row-diff regex the workflow's change-detection bot uses
|
||
# (.github/workflows/tree-sitter-upgrade-readiness.yml) — byte-identical so a matrix
|
||
# format change that would silently break change-detection fails here. Group 2 is
|
||
# ONLY the Status cell ([^|]+? before the final `|$`).
|
||
_ROW_DIFF_RE = re.compile(r"\| `(tree-sitter-[^`]+)` \|.*\| ([^|]+?) \|$", re.M)
|
||
# Mirrors the scheduled issue-update summary extraction in
|
||
# tree-sitter-upgrade-readiness.yml. If the report prose changes again, the issue
|
||
# comment should not silently degrade to "?/? ready. ? blocker(s)".
|
||
_ISSUE_READY_RE = re.compile(
|
||
r"- (\d+)/(\d+) npm-installed grammars already accept tree-sitter@"
|
||
)
|
||
_ISSUE_BLOCKER_RE = re.compile(r"\*\*Blocked\*\* — (\d+) grammars? ")
|
||
|
||
|
||
def _physical_vendor_grammars() -> set[str]:
|
||
vendor = _REPO_ROOT / "gitnexus" / "vendor"
|
||
return {
|
||
p.name
|
||
for p in vendor.iterdir()
|
||
if p.is_dir() and p.name.startswith("tree-sitter-")
|
||
}
|
||
|
||
|
||
def _render_report() -> tuple[str, int]:
|
||
"""Run main() with network mocked to mirror PRODUCTION; return (md, exit_code).
|
||
|
||
- npm grammars resolve to a permissive "Ready" peer dep, so the only blockers
|
||
left are the held vendored grammars (tree-sitter-c, tree-sitter-kotlin) plus
|
||
the intentionally-pinned tree-sitter-cpp — letting us assert holds are
|
||
load-bearing (exit code stays non-zero because of them).
|
||
- npm_view_json records its calls so we can prove vendored grammars are never
|
||
npm-queried.
|
||
- fetch_text mirrors the real workflow: upstream parser.c resolves to a real
|
||
ABI (committed upstream), commit endpoints return a sha — EXCEPT swift's
|
||
upstream, whose parser.c is generated at build time and so is unreachable
|
||
(None). That single miss exercises the labeled-sentinel path; every other
|
||
cell must be a real value, never a bare '?'.
|
||
"""
|
||
npm_calls: list[str] = []
|
||
|
||
def fake_npm_view_json(pkg: str):
|
||
npm_calls.append(pkg)
|
||
return {"version": "9.9.9", "peerDependencies": {"tree-sitter": "^0.25.0"}}
|
||
|
||
def fake_fetch_text(url: str, timeout: int = 8):
|
||
if "parser.c" in url:
|
||
# swift's upstream parser.c is generated at build time → unreachable;
|
||
# the others ship a committed parser.c.
|
||
if "alex-pinkus" in url:
|
||
return None
|
||
return "#define LANGUAGE_VERSION 14\n#define STATE_COUNT 1\n"
|
||
if "/commits/" in url:
|
||
return json.dumps({"sha": "0123456789abcdef"})
|
||
# package.json (relaxed-peer probe) etc. — not needed for these assertions.
|
||
return None
|
||
|
||
buf = io.StringIO()
|
||
with mock.patch.object(readiness, "npm_view_json", side_effect=fake_npm_view_json), \
|
||
mock.patch.object(readiness, "fetch_text", side_effect=fake_fetch_text), \
|
||
contextlib.redirect_stdout(buf):
|
||
code = readiness.main()
|
||
report = buf.getvalue()
|
||
_render_report.last_npm_calls = npm_calls # type: ignore[attr-defined]
|
||
return report, code
|
||
|
||
|
||
class ManifestClassification(TestCase):
|
||
def test_manifest_matches_physical_vendor_dirs(self):
|
||
"""Consistency guard: the manifest set == the gitnexus/vendor/tree-sitter-*
|
||
dirs. Vendoring a grammar without a manifest entry (or vice-versa) fails —
|
||
this is what keeps the two tree-sitter workflows aligned (#858)."""
|
||
manifest_names = {
|
||
g["name"]
|
||
for g in json.loads(_MANIFEST.read_text())["grammars"].values()
|
||
}
|
||
self.assertEqual(manifest_names, _physical_vendor_grammars())
|
||
|
||
def test_vendored_names_loaded_from_manifest(self):
|
||
self.assertEqual(set(readiness.VENDORED_NAMES), _physical_vendor_grammars())
|
||
# npm-installed grammars must NOT be classified vendored.
|
||
self.assertNotIn("tree-sitter-cpp", readiness.VENDORED_NAMES)
|
||
self.assertNotIn("tree-sitter-go", readiness.VENDORED_NAMES)
|
||
|
||
def test_c_carries_a_hold_cpp_does_not(self):
|
||
self.assertTrue(readiness.VENDORED["tree-sitter-c"]["hold"])
|
||
self.assertNotIn("tree-sitter-c", readiness.INTENTIONAL_PINS)
|
||
# cpp stays an npm intentional pin.
|
||
self.assertIn("tree-sitter-cpp", readiness.INTENTIONAL_PINS)
|
||
|
||
def test_vendored_names_are_a_subset_of_GRAMMARS(self):
|
||
# The report + --assert-current iterate the hardcoded GRAMMARS dict for
|
||
# upstream-drift coords. A vendored grammar present in the manifest but
|
||
# missing from GRAMMARS would be silently dropped from both — re-creating
|
||
# the cross-workflow divergence the manifest exists to kill (#858). Guard it.
|
||
missing = set(readiness.VENDORED_NAMES) - set(readiness.GRAMMARS)
|
||
self.assertEqual(missing, set(), f"manifest grammars missing from GRAMMARS: {missing}")
|
||
|
||
def test_missing_manifest_raises_a_clear_error(self):
|
||
import pathlib
|
||
import tempfile
|
||
|
||
with tempfile.TemporaryDirectory() as d:
|
||
with mock.patch.object(readiness, "REPO_ROOT", pathlib.Path(d)):
|
||
with self.assertRaises(SystemExit) as ctx:
|
||
readiness.load_vendored_manifest()
|
||
self.assertIn("vendored-grammars manifest", str(ctx.exception))
|
||
|
||
def test_malformed_manifest_raises_a_clear_error(self):
|
||
import pathlib
|
||
import tempfile
|
||
|
||
with tempfile.TemporaryDirectory() as d:
|
||
gh = pathlib.Path(d) / ".github"
|
||
gh.mkdir()
|
||
(gh / "vendored-grammars.json").write_text("{ not valid json", encoding="utf-8")
|
||
with mock.patch.object(readiness, "REPO_ROOT", pathlib.Path(d)):
|
||
with self.assertRaises(SystemExit) as ctx:
|
||
readiness.load_vendored_manifest()
|
||
self.assertIn("not valid JSON", str(ctx.exception))
|
||
|
||
def test_path_traversal_grammar_name_is_rejected(self):
|
||
import pathlib
|
||
import tempfile
|
||
|
||
bad = '{"grammars": {"evil": {"name": "../etc"}}}'
|
||
with tempfile.TemporaryDirectory() as d:
|
||
gh = pathlib.Path(d) / ".github"
|
||
gh.mkdir()
|
||
(gh / "vendored-grammars.json").write_text(bad, encoding="utf-8")
|
||
with mock.patch.object(readiness, "REPO_ROOT", pathlib.Path(d)):
|
||
with self.assertRaises(SystemExit) as ctx:
|
||
readiness.load_vendored_manifest()
|
||
self.assertIn("invalid grammar name", str(ctx.exception))
|
||
|
||
|
||
class AssertCurrent(TestCase):
|
||
"""The offline #1922 ABI gate (--assert-current) must stay hermetic — it reads
|
||
vendored ABIs from the repo, never the network. (Regression guard: a prior
|
||
revision routed vendored grammars through vendored_drift_summary, which fetches
|
||
upstream parser.c + commit sha, silently breaking the 'hermetic and offline'
|
||
contract — #858 review.)"""
|
||
|
||
def _run_assert_current(self):
|
||
import urllib.request
|
||
|
||
def explode(*a, **k):
|
||
raise AssertionError("--assert-current attempted a network call")
|
||
|
||
buf = io.StringIO()
|
||
with mock.patch.object(urllib.request, "urlopen", side_effect=explode), \
|
||
contextlib.redirect_stdout(buf):
|
||
code = readiness.assert_current()
|
||
return buf.getvalue(), code
|
||
|
||
def test_assert_current_is_network_free_and_passes(self):
|
||
report, code = self._run_assert_current() # raises if any urlopen fires
|
||
self.assertEqual(code, 0)
|
||
# All 5 vendored grammars are introspected from the repo (ABI 14), not skipped.
|
||
for name in readiness.VENDORED_NAMES:
|
||
self.assertIn(f"{name}: vendored ABI", report)
|
||
|
||
def test_assert_current_fails_an_out_of_range_vendored_abi(self):
|
||
# vendored_abi_from_repo is the local-read injection point: force one
|
||
# grammar out of the current runtime's ABI window and assert the gate trips.
|
||
real = readiness.vendored_abi_from_repo
|
||
|
||
def fake(name, parser_path):
|
||
return 99 if name == "tree-sitter-dart" else real(name, parser_path)
|
||
|
||
import urllib.request
|
||
buf = io.StringIO()
|
||
with mock.patch.object(readiness, "vendored_abi_from_repo", side_effect=fake), \
|
||
mock.patch.object(urllib.request, "urlopen", side_effect=AssertionError("network")), \
|
||
contextlib.redirect_stdout(buf):
|
||
code = readiness.assert_current()
|
||
self.assertEqual(code, 1)
|
||
self.assertIn("tree-sitter-dart", buf.getvalue())
|
||
self.assertIn("outside current runtime range", buf.getvalue())
|
||
|
||
|
||
class FetchHelperReadPhaseErrors(TestCase):
|
||
"""Read-phase transport failures — raised by resp.read() AFTER urlopen has
|
||
returned (ConnectionResetError, ssl.SSLError, socket.timeout,
|
||
http.client.IncompleteRead) — are NOT urllib.error.URLError subclasses
|
||
(urllib only wraps connect-phase OSErrors). A prior revision's narrow except
|
||
tuple let them escape npm_view_json / fetch_text, crash main(), and empty
|
||
stdout — which makes the workflow's requireMatch throw on a non-drift
|
||
scheduled run. The helpers must swallow them to None so the grammar routes to
|
||
the fetch_failed blocker bucket and the report still renders completely."""
|
||
|
||
@staticmethod
|
||
def _patch_urlopen(*, read_returns=None, read_raises=None):
|
||
class _Resp:
|
||
def __enter__(self):
|
||
return self
|
||
|
||
def __exit__(self, *exc):
|
||
return False
|
||
|
||
def read(self, *a, **k):
|
||
if read_raises is not None:
|
||
raise read_raises
|
||
return read_returns
|
||
|
||
def _fake_urlopen(*a, **k):
|
||
return _Resp()
|
||
|
||
import urllib.request
|
||
|
||
return mock.patch.object(urllib.request, "urlopen", side_effect=_fake_urlopen)
|
||
|
||
def test_npm_view_json_swallows_read_phase_connection_reset(self):
|
||
# ConnectionResetError is an OSError but NOT a URLError — the broadened
|
||
# OSError clause must catch it so the helper returns None, not raises.
|
||
with mock.patch.object(readiness, "OFFLINE", False), self._patch_urlopen(
|
||
read_raises=ConnectionResetError("peer reset mid-body")
|
||
):
|
||
self.assertIsNone(readiness.npm_view_json("tree-sitter-anything"))
|
||
|
||
def test_fetch_text_swallows_read_phase_incomplete_read(self):
|
||
# http.client.IncompleteRead is an HTTPException (not OSError), so it must
|
||
# be named explicitly in the except tuple.
|
||
with mock.patch.object(readiness, "OFFLINE", False), self._patch_urlopen(
|
||
read_raises=http.client.IncompleteRead(partial=b"half")
|
||
):
|
||
self.assertIsNone(readiness.fetch_text("https://example.com/parser.c"))
|
||
|
||
def test_npm_view_json_still_swallows_bad_json(self):
|
||
# JSONDecodeError is a ValueError, not an OSError — broadening the tuple
|
||
# must not drop it. Non-JSON body still yields None.
|
||
with mock.patch.object(readiness, "OFFLINE", False), self._patch_urlopen(
|
||
read_returns=b"<<not json>>"
|
||
):
|
||
self.assertIsNone(readiness.npm_view_json("tree-sitter-anything"))
|
||
|
||
|
||
class ReportRendering(TestCase):
|
||
@classmethod
|
||
def setUpClass(cls):
|
||
cls.report, cls.code = _render_report()
|
||
cls.rows = dict(_ROW_DIFF_RE.findall(cls.report))
|
||
|
||
def test_no_bare_question_mark_anywhere(self):
|
||
# The only legitimate '?' is the "Satisfies 0.25?" column header.
|
||
sanitized = self.report.replace("Satisfies 0.25?", "Satisfies 0.25")
|
||
self.assertNotIn("?", sanitized, "report still contains a bare '?' placeholder")
|
||
|
||
def test_malformed_npm_version_renders_unknown_in_prose_not_bare_question(self):
|
||
# A successful (200) npm /latest response that omits `version` leaves
|
||
# npm_version == "?"; the grammar is still bucketed (fetch did not fail), so
|
||
# its disposition PROSE line must show the labeled sentinel, never a bare '?'.
|
||
def fake_npm(pkg: str):
|
||
if pkg == "tree-sitter-go":
|
||
return {"peerDependencies": {"tree-sitter": "^0.25.0"}} # no 'version'
|
||
return {"version": "9.9.9", "peerDependencies": {"tree-sitter": "^0.25.0"}}
|
||
|
||
def fake_fetch(url: str, timeout: int = 8):
|
||
if "parser.c" in url and "alex-pinkus" not in url:
|
||
return "#define LANGUAGE_VERSION 14\n"
|
||
if "/commits/" in url:
|
||
return json.dumps({"sha": "0123456789abcdef"})
|
||
return None
|
||
|
||
buf = io.StringIO()
|
||
with mock.patch.object(readiness, "npm_view_json", side_effect=fake_npm), \
|
||
mock.patch.object(readiness, "fetch_text", side_effect=fake_fetch), \
|
||
contextlib.redirect_stdout(buf):
|
||
readiness.main()
|
||
report = buf.getvalue()
|
||
sanitized = report.replace("Satisfies 0.25?", "Satisfies 0.25")
|
||
self.assertNotIn("?", sanitized)
|
||
# The Ready bucket prose line for go shows the labeled 'unknown', not '?'.
|
||
self.assertRegex(report, r"`tree-sitter-go`.*npm latest `unknown`")
|
||
|
||
def test_every_vendored_grammar_shows_numeric_abi_not_question_mark(self):
|
||
for name in readiness.VENDORED_NAMES:
|
||
row = self._matrix_row(name)
|
||
cells = [c.strip() for c in row.strip().strip("|").split("|")]
|
||
abi_cell = cells[5] # Grammar|Pinned|npm|Peer|Satisfies|ABI|UpstreamABI|Status
|
||
self.assertRegex(
|
||
abi_cell, r"^\d+$",
|
||
f"{name} ABI cell is '{abi_cell}', expected a number (read from vendor/)",
|
||
)
|
||
|
||
def test_proto_is_never_npm_queried(self):
|
||
# github-only vendored grammars must skip the npm peer-dep path entirely,
|
||
# which is what removes the old "? (fetch failed)" for tree-sitter-proto.
|
||
self.assertNotIn("tree-sitter-proto", _render_report.last_npm_calls)
|
||
self.assertNotIn("tree-sitter-dart", _render_report.last_npm_calls)
|
||
self.assertNotIn("Could not check", self.report)
|
||
self.assertNotIn("fetch failed", self.report)
|
||
|
||
def test_held_c_renders_held_and_keeps_exit_nonzero(self):
|
||
# Status is the last matrix cell (the row-diff regex captures the whole
|
||
# tail, not just status, so read the cell directly).
|
||
cells = [c.strip() for c in self._matrix_row("tree-sitter-c").strip().strip("|").split("|")]
|
||
self.assertEqual(cells[-1], "Vendored — held")
|
||
self.assertIn("**Held:**", self.report)
|
||
# With every npm grammar mocked to "Ready", the ONLY remaining blocker is
|
||
# the held c — so a non-zero exit proves the hold is treated as a blocker.
|
||
self.assertEqual(self.code, 1)
|
||
|
||
def test_upstream_abi_miss_uses_labeled_sentinel(self):
|
||
# swift's upstream parser.c is unreachable (mocked None), so its
|
||
# upstream-ABI cell is the labeled 'n/a' token, never a bare '?'.
|
||
cells = [c.strip() for c in self._matrix_row("tree-sitter-swift").strip().strip("|").split("|")]
|
||
self.assertEqual(cells[6], "n/a") # Upstream ABI column
|
||
|
||
def test_row_diff_regex_captures_all_fifteen_grammar_statuses(self):
|
||
# The change-detection bot keys on this regex: group 1 = grammar name,
|
||
# group 2 = the Status cell ONLY (not the whole tail). It must match every
|
||
# row after the format change so status transitions keep being detected.
|
||
self.assertEqual(len(self.rows), 15)
|
||
for name in readiness.VENDORED_NAMES:
|
||
self.assertIn(name, self.rows)
|
||
# group 2 is the Status cell — held c renders exactly "Vendored — held",
|
||
# and no captured status contains a pipe (proves cell-scoped capture).
|
||
self.assertEqual(self.rows["tree-sitter-c"], "Vendored — held")
|
||
for status in self.rows.values():
|
||
self.assertNotIn("|", status)
|
||
|
||
def test_issue_update_summary_regex_matches_current_report(self):
|
||
ready = _ISSUE_READY_RE.search(self.report)
|
||
blockers = _ISSUE_BLOCKER_RE.search(self.report)
|
||
self.assertIsNotNone(ready)
|
||
self.assertIsNotNone(blockers)
|
||
# Counts are derived from _render_report()'s mock corpus (all npm peer
|
||
# deps mocked permissive): of the 10 npm-installed grammars, 9 render
|
||
# Ready and 1 — tree-sitter-cpp — is the intentional pin (#1242), so it is
|
||
# not counted ready. The 3 blockers are that same pinned tree-sitter-cpp
|
||
# plus two held vendored grammars: ABI-held tree-sitter-c (#1242/#858) and
|
||
# tree-sitter-kotlin (pinned to an unreleased fwcd main commit for `fun
|
||
# interface` support — ABI 14 is in range, but a hold counts as a blocker
|
||
# until it is lifted). If a grammar is added/removed or a pin/hold changes,
|
||
# update _render_report()'s mock AND these expected counts together; a
|
||
# mismatch here means the report prose drifted, not the regex.
|
||
self.assertEqual(ready.groups(), ("9", "10"))
|
||
self.assertEqual(blockers.group(1), "3")
|
||
|
||
def _matrix_row(self, name: str) -> str:
|
||
for line in self.report.splitlines():
|
||
if line.startswith(f"| `{name}` |"):
|
||
return line
|
||
# Explicit terminating raise (not self.fail, which CodeQL doesn't model as
|
||
# NoReturn) so the function has no implicit fall-through return (CodeQL 754).
|
||
raise AssertionError(f"no matrix row for {name}")
|
||
|
||
|
||
class OfflineMode(TestCase):
|
||
"""--offline must render the report touching ZERO network — vendored ABIs come
|
||
from the repo, npm columns are marked unverified. This is what makes the
|
||
network-dependent report deterministically testable in air-gapped CI."""
|
||
|
||
def _render_offline(self):
|
||
import urllib.request
|
||
|
||
def explode(*a, **k):
|
||
raise AssertionError("network call attempted in --offline mode")
|
||
|
||
buf = io.StringIO()
|
||
with mock.patch.object(readiness, "OFFLINE", True), \
|
||
mock.patch.object(urllib.request, "urlopen", side_effect=explode), \
|
||
contextlib.redirect_stdout(buf):
|
||
code = readiness.main()
|
||
return buf.getvalue(), code
|
||
|
||
def test_offline_touches_no_network_and_still_renders(self):
|
||
report, code = self._render_offline() # raises if any urlopen fires
|
||
self.assertIn("Offline mode", report)
|
||
# Vendored grammars are introspected from the repo → real ABI 14, not a miss.
|
||
for name in readiness.VENDORED_NAMES:
|
||
row = next(l for l in report.splitlines() if l.startswith(f"| `{name}` |"))
|
||
cells = [c.strip() for c in row.strip().strip("|").split("|")]
|
||
self.assertRegex(cells[5], r"^\d+$", f"{name} vendored ABI missing offline")
|
||
|
||
def test_offline_marks_npm_grammars_offline_not_fetch_failed(self):
|
||
report, _ = self._render_offline()
|
||
self.assertIn("(offline)", report)
|
||
self.assertNotIn("fetch failed", report) # honest: skipped, not failed
|
||
|
||
def test_offline_report_has_no_bare_question_mark(self):
|
||
report, _ = self._render_offline()
|
||
sanitized = report.replace("Satisfies 0.25?", "Satisfies 0.25")
|
||
self.assertNotIn("?", sanitized)
|
||
|
||
|
||
class VendoredAbiBranches(TestCase):
|
||
"""main()'s vendored-ABI classification reads through vendored_abi_from_repo
|
||
(the same local-read seam --assert-current uses), so a single patch drives the
|
||
out-of-range and prebuilt-only branches that no real vendor dir can trigger
|
||
today (all ship parser.c at ABI 14)."""
|
||
|
||
def _render_with_vendored_abi(self, override):
|
||
"""Render main() with the standard production-faithful network mock plus a
|
||
vendored_abi_from_repo override (dict: name -> int|None; others read real)."""
|
||
real = readiness.vendored_abi_from_repo
|
||
|
||
def abi_seam(name, parser_path):
|
||
return override[name] if name in override else real(name, parser_path)
|
||
|
||
def fake_npm(pkg):
|
||
return {"version": "9.9.9", "peerDependencies": {"tree-sitter": "^0.25.0"}}
|
||
|
||
def fake_fetch(url, timeout=8):
|
||
if "parser.c" in url and "alex-pinkus" not in url:
|
||
return "#define LANGUAGE_VERSION 14\n"
|
||
if "/commits/" in url:
|
||
return json.dumps({"sha": "0123456789abcdef"})
|
||
return None
|
||
|
||
buf = io.StringIO()
|
||
with mock.patch.object(readiness, "vendored_abi_from_repo", side_effect=abi_seam), \
|
||
mock.patch.object(readiness, "npm_view_json", side_effect=fake_npm), \
|
||
mock.patch.object(readiness, "fetch_text", side_effect=fake_fetch), \
|
||
contextlib.redirect_stdout(buf):
|
||
code = readiness.main()
|
||
return buf.getvalue(), code
|
||
|
||
def _row(self, report, name):
|
||
line = next(l for l in report.splitlines() if l.startswith(f"| `{name}` |"))
|
||
return [c.strip() for c in line.strip().strip("|").split("|")]
|
||
|
||
def test_out_of_range_vendored_abi_is_a_blocker(self):
|
||
# Force tree-sitter-dart's vendored ABI outside the target range (13–15).
|
||
report, code = self._render_with_vendored_abi({"tree-sitter-dart": 99})
|
||
cells = self._row(report, "tree-sitter-dart")
|
||
self.assertEqual(cells[-1], "Vendored (ABI out of range)")
|
||
self.assertEqual(cells[5], "99")
|
||
self.assertEqual(code, 1) # out-of-range vendored grammar is a blocker
|
||
|
||
def test_prebuilt_only_vendored_abi_renders_prebuilt_not_question(self):
|
||
# vendored_abi None (a future binary-only vendor with no parser.c).
|
||
report, _ = self._render_with_vendored_abi({"tree-sitter-dart": None})
|
||
cells = self._row(report, "tree-sitter-dart")
|
||
self.assertEqual(cells[5], "prebuilt") # labeled, never a bare '?'
|
||
self.assertEqual(cells[4], "Yes") # prebuilt is assumed target-compatible
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|