Files
bytecii ca89dc5970 fix(parity): github ref default, NAME_MAX byte budget, browser registry onto node's config shape
Two py<->ts divergences, one structural alignment, one swept-and-gated class
of config noise.

1. GitHub `ref` defaulted to the literal "main" in python where TypeScript
   resolves `config.ref ?? repoInfo.default_branch`, so every repository whose
   default branch is something else 404d on the one tree fetch the mount is
   built on and read as empty. `GitHubConfig.ref` is now optional, and
   `ensure_ref` resolves it lazily beside the existing `ensure_default_branch`
   -- python's constructor deliberately does no network. Every reader that
   needs a concrete ref goes through it. `mirage.accessor.github` is fully
   annotated and leaves mypy's untyped-defs allowlist.

2. `sanitize_label` capped characters where NAME_MAX counts bytes, which the
   constant's own comment already spelled out. gdocs/gsheets/gslides rendered
   ~367-byte filenames for a CJK title and gmail/email ~269; ext4 and APFS
   reject those. `sanitize_label`/`sanitizeLabel` grew a byte budget and every
   compose site passes what the date, id and suffix leave. Found alongside: TS
   email's hand-rolled copy of the sanitizer used JS's ASCII-only `\w` (so
   non-ASCII subjects became underscores there and stayed intact in python),
   and three sites re-composed `<subject>__<id>.<ext>` independently of
   readdir, so a search hit could name a path that does not exist.

3. The browser registry hand-rolled `normalizeFields` per entry and cast
   through 7 `*BrowserCtorConfig` interfaces -- hand-written twins of the real
   config types. It now delegates to a `normalizeXConfig` beside each config,
   as node's registry does; the interfaces and 22 double casts are gone.

4. 95 rename entries that only restated `snakeToCamel` deleted across 24
   files, with `scripts/check_normalize_renames.py` and a CI step to keep them
   from returning.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 02:34:03 -07:00

286 lines
9.6 KiB
Python

# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import logging
from unittest.mock import patch
import pytest
from mirage.accessor.github import GitHubAccessor
from mirage.cache.index import NULL_INDEX
from mirage.cache.index.ram import RAMIndexCacheStore
from mirage.core.github.config import GitHubConfig
from mirage.core.github.tree import (ensure_live_index, ensure_tree,
fetch_dir_tree, fetch_tree, index_rows)
from mirage.core.github.tree_entry import TreeEntry
@pytest.fixture
def config():
return GitHubConfig(token="ghp_test")
@pytest.mark.asyncio
@patch("mirage.core.github.tree.github_get")
async def test_fetch_tree_parses_entries(mock_get, config):
mock_get.return_value = {
"truncated":
False,
"tree": [
{
"path": "src",
"type": "tree",
"sha": "aaa",
"size": None
},
{
"path": "src/main.py",
"type": "blob",
"sha": "bbb",
"size": 120
},
],
}
tree, truncated = await fetch_tree(config, "acme", "proj", "main")
assert "src" in tree
assert "src/main.py" in tree
assert tree["src"] == TreeEntry(path="src",
type="tree",
sha="aaa",
size=None)
assert tree["src/main.py"] == TreeEntry(path="src/main.py",
type="blob",
sha="bbb",
size=120)
@pytest.mark.asyncio
@patch("mirage.core.github.tree.github_get")
async def test_fetch_tree_excludes_submodule_gitlinks(mock_get, config):
mock_get.return_value = {
"truncated":
False,
"tree": [
{
"path": "extern",
"mode": "160000",
"type": "commit",
"sha": "ccc"
},
{
"path": "main.py",
"type": "blob",
"sha": "bbb",
"size": 7
},
],
}
tree, _ = await fetch_tree(config, "acme", "proj", "main")
assert "extern" not in tree
assert list(tree) == ["main.py"]
@pytest.mark.asyncio
@patch("mirage.core.github.tree.github_get")
async def test_fetch_dir_tree_excludes_submodule_gitlinks(mock_get, config):
mock_get.return_value = {
"tree": [
{
"path": "extern",
"mode": "160000",
"type": "commit",
"sha": "ccc"
},
{
"path": "main.py",
"type": "blob",
"sha": "bbb",
"size": 7
},
]
}
entries = await fetch_dir_tree(config, "acme", "proj", "sha1")
assert [e.path for e in entries] == ["main.py"]
@pytest.mark.asyncio
@patch("mirage.core.github.tree.github_get")
async def test_fetch_tree_truncation_warning(mock_get, config, caplog):
mock_get.return_value = {"truncated": True, "tree": []}
with caplog.at_level(logging.WARNING):
await fetch_tree(config, "acme", "proj", "main")
assert "truncated" in caplog.text
@pytest.mark.asyncio
@patch("mirage.core.github.tree.github_get")
async def test_fetch_tree_passes_params(mock_get, config):
mock_get.return_value = {"tree": []}
await fetch_tree(config, "acme", "proj", "v1")
mock_get.assert_awaited_once_with(
config.token,
"/repos/{owner}/{repo}/git/trees/{ref}",
params={"recursive": "1"},
base_url=None,
owner="acme",
repo="proj",
ref="v1",
)
def _tree_payload() -> dict:
return {
"truncated":
False,
"tree": [
{
"path": "data",
"type": "tree",
"sha": "t1",
"size": None
},
{
"path": "data/keep.txt",
"type": "blob",
"sha": "b1",
"size": 4
},
],
}
def _accessor(config):
tree = {
"data":
TreeEntry(path="data", type="tree", sha="t1", size=None),
"data/keep.txt":
TreeEntry(path="data/keep.txt", type="blob", sha="b1", size=4),
}
return GitHubAccessor(config, "acme", "proj", "main", "main", tree=tree)
@pytest.mark.asyncio
@patch("mirage.core.github.tree.github_get")
async def test_ensure_live_index_refetches_the_build_tree(mock_get, config):
# The build tree is only true at build time: a mount's first read can
# come long after it, so reusing it would key an index built from a
# repository several external writes ago.
mock_get.return_value = _tree_payload()
index = RAMIndexCacheStore(ttl=600)
accessor = _accessor(config)
assert await ensure_live_index(accessor, index, "/gh") is True
mock_get.assert_awaited_once()
assert (await index.list_dir("/gh/data")).entries == ["/gh/data/keep.txt"]
@pytest.mark.asyncio
@patch("mirage.core.github.tree.github_get")
async def test_ensure_live_index_refetches_a_dropped_listing(mock_get, config):
mock_get.return_value = _tree_payload()
index = RAMIndexCacheStore(ttl=600)
accessor = _accessor(config)
await ensure_live_index(accessor, index, "/gh")
# What invalidation does: drop the row rather than expire it, which
# is why the readers' EXPIRED probe never fires.
await index.invalidate_dir("/gh")
await index.invalidate_dir("/gh/data")
assert await ensure_live_index(accessor, index, "/gh") is True
assert mock_get.await_count == 2
assert (await index.list_dir("/gh/data")).entries == ["/gh/data/keep.txt"]
@pytest.mark.asyncio
@patch("mirage.core.github.tree.github_get")
async def test_ensure_live_index_leaves_a_live_index_alone(mock_get, config):
mock_get.return_value = _tree_payload()
index = RAMIndexCacheStore(ttl=600)
accessor = _accessor(config)
await ensure_live_index(accessor, index, "/gh")
mock_get.reset_mock()
assert await ensure_live_index(accessor, index, "/gh") is False
mock_get.assert_not_awaited()
@pytest.mark.asyncio
@patch("mirage.core.github.tree.github_get")
async def test_ensure_live_index_skips_a_truncated_tree(mock_get, config):
index = RAMIndexCacheStore(ttl=600)
accessor = _accessor(config)
accessor.truncated = True
assert await ensure_live_index(accessor, index, "/gh") is False
mock_get.assert_not_awaited()
@pytest.mark.asyncio
async def test_ensure_live_index_skips_the_null_index(config):
assert await ensure_live_index(_accessor(config), NULL_INDEX, "") is False
def test_index_rows_key_by_mount_absolute_path():
# Every other backend keys its index this way, which is what lets the
# shared CacheManager spell an eviction without knowing the backend.
tree = {
"data":
TreeEntry(path="data", type="tree", sha="t1", size=None),
"data/keep.txt":
TreeEntry(path="data/keep.txt", type="blob", sha="b1", size=4),
}
entries, children = index_rows(tree, "/gh")
assert sorted(entries) == ["/gh/data", "/gh/data/keep.txt"]
assert sorted(children) == ["/gh", "/gh/data"]
def test_index_rows_root_mount_keeps_bare_paths():
entries, children = index_rows(
{"a.txt": TreeEntry(path="a.txt", type="blob", sha="b", size=1)}, "")
assert sorted(entries) == ["/a.txt"]
assert sorted(children) == ["/"]
def test_index_rows_gives_an_empty_repo_a_root_row():
_entries, children = index_rows({}, "/gh")
assert children == {"/gh": []}
@pytest.mark.asyncio
@patch("mirage.core.github.repo.github_get")
@patch("mirage.core.github.tree.github_get")
async def test_an_unpinned_mount_reads_the_repos_default_branch(
mock_tree_get, mock_repo_get, config):
"""An unresolved ref must be settled before the tree is fetched.
``accessor.ref`` is None until something resolves it, so reading it
straight sends `ref=None` to the one request the whole mount is built
on. This pins the resolution, not the config default -- the mount that
supplies the default lives a layer up, in
tests/resource/github/test_lazy_hydration.py.
"""
mock_repo_get.return_value = {"default_branch": "master"}
mock_tree_get.return_value = {"truncated": False, "tree": []}
accessor = GitHubAccessor(config, "acme", "proj")
await ensure_tree(accessor)
assert mock_tree_get.await_args.kwargs["ref"] == "master"
@pytest.mark.asyncio
@patch("mirage.core.github.repo.github_get")
@patch("mirage.core.github.tree.github_get")
async def test_a_pinned_mount_reads_its_ref_and_never_asks_for_the_branch(
mock_tree_get, mock_repo_get, config):
mock_tree_get.return_value = {"truncated": False, "tree": []}
accessor = GitHubAccessor(config, "acme", "proj", "release-2")
await ensure_tree(accessor)
assert mock_tree_get.await_args.kwargs["ref"] == "release-2"
mock_repo_get.assert_not_awaited()