Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 29 additions & 5 deletions local/fork-surface.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,28 @@
# Upstream paths the fork is allowed to modify.
allowlist = [
# Fork-surface policy, enforced by local/scripts/check_fork_surface.py.
#
# The check diffs HEAD against the upstream tag in local/UPSTREAM_VERSION
# (`git diff --numstat <tag>...HEAD`) and fails when a touched path is neither
# fork-owned nor allowed, or when the upstream edits exceed the budget.
#
# Schema:
# allow list of globs Upstream paths the fork may edit (the hook files).
# `allowlist` is accepted as an alias.
# generated list of globs Upstream paths that are allowed but excluded from
# the budget (e.g. Cargo.lock; verify by regeneration).
# ignore list of globs OPTIONAL extra fork-owned globs, appended to the
# built-in set, which is always ignored: local/**,
# codex-rs/local/**, boundaries/**, .claude/**,
# .codex/skills/**, .github/workflows/local-*.yml,
# .github/workflows/upstream-sync.yml
# [budget]
# max_lines integer Max added+removed lines over non-ignored,
# non-generated files.
# max_files integer Max count of those files.
#
# Globs use `*` (within one path segment), `**` (across segments), `?`.
# Paths are repo-relative with forward slashes.

allow = [
"codex-rs/Cargo.toml",
"codex-rs/cli/src/main.rs",
"codex-rs/cli/build.rs",
Expand All @@ -15,6 +38,7 @@ allowlist = [
generated = ["codex-rs/Cargo.lock"]

[budget]
# Spike-pass budget plus H8.
max_lines = 91
max_files = 8
# Measured 122 lines / 11 files across H1-H4 and H8 (Cargo.toml 1, H1 14, H2 36, H3 19, H4 18,
# H8 34 incl. tests) plus ~10% headroom.
max_lines = 135
max_files = 12
151 changes: 151 additions & 0 deletions local/scripts/check_fork_surface.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
#!/usr/bin/env python3
"""Enforce the codex-local fork surface (plan Phase 6). Stdlib only.

Prints one JSON object {ok, upstream, files[], total_lines, violations[]} and
exits 0 when ok, 1 on violations, 2 on a tool error (bad config, missing tag).
The policy schema is documented at the top of local/fork-surface.toml.
"""
import argparse
import json
import re
import subprocess
import sys
import tomllib
from pathlib import Path

# Fork-owned paths: never counted, never need an allowlist entry.
BUILTIN_IGNORE = [
"codex-rs/local/**",
"local/**",
"boundaries/**",
".claude/**",
".codex/skills/**",
".github/workflows/local-*.yml",
".github/workflows/upstream-sync.yml",
]


def glob_re(pattern):
out, i = [], 0
while i < len(pattern):
c = pattern[i]
if pattern.startswith("**/", i):
out.append("(?:.*/)?")
i += 3
continue
if pattern.startswith("**", i):
out.append(".*")
i += 2
continue
out.append("[^/]*" if c == "*" else "[^/]" if c == "?" else re.escape(c))
i += 1
return re.compile("".join(out) + r"\Z")


def matches(path, patterns):
return any(glob_re(p).match(path) for p in patterns)


def git(root, *args):
r = subprocess.run(["git", "-C", str(root), *args], capture_output=True, text=True)
if r.returncode:
raise RuntimeError(f"git {' '.join(args)}: {r.stderr.strip()}")
return r.stdout


def resolve_ref(root, tag):
for ref in (tag, f"upstream/{tag}"):
try:
git(root, "rev-parse", "--verify", "--quiet", ref + "^{commit}")
return ref
except RuntimeError:
pass
raise RuntimeError(f"upstream ref not found: {tag} (also tried upstream/{tag})")


def local_dependency_violations(root):
"""No crate outside codex-local-* may depend on codex-local-*, and only
codex-local-cli may depend on another local crate (it is the only root).

sc-lint has no wildcard/default-allow mode (allowed_dependencies is
mandatory, so local crates could not depend on arbitrary upstream crates),
so this rule is enforced here, generically, with no crate lists.
"""
ws = Path(root) / "codex-rs"
if not (ws / "Cargo.toml").exists():
return []
r = subprocess.run(["cargo", "metadata", "--no-deps", "--format-version", "1"],
cwd=ws, capture_output=True, text=True)
if r.returncode:
raise RuntimeError("cargo metadata: " + r.stderr.strip()[-300:])
out = []
for pkg in json.loads(r.stdout)["packages"]:
name = pkg["name"]
for dep in pkg["dependencies"]:
if not dep["name"].startswith("codex-local-"):
continue
if not name.startswith("codex-local-"):
out.append({"kind": "upstream_depends_on_local", "crate": name,
"dependency": dep["name"]})
elif name != "codex-local-cli" and dep["name"] != name:
out.append({"kind": "local_not_callable_from_cli_only", "crate": name,
"dependency": dep["name"]})
elif name == "codex-local-cli" and dep["name"] == name:
continue
return out


def check(root):
root = Path(root)
tag = (root / "local/UPSTREAM_VERSION").read_text().strip()
cfg = tomllib.loads((root / "local/fork-surface.toml").read_text())
allow = cfg.get("allow", cfg.get("allowlist", []))
generated = cfg.get("generated", [])
ignore = BUILTIN_IGNORE + cfg.get("ignore", [])
budget = cfg.get("budget", {})
ref = resolve_ref(root, tag)

files, violations, total = [], [], 0
for line in git(root, "diff", "--numstat", "--no-renames", f"{ref}...HEAD").splitlines():
added, removed, path = line.split("\t", 2)
if matches(path, ignore) or matches(path, generated):
continue
# Binary files report "-"; they count as a file but add no lines.
n = (int(added) + int(removed)) if added != "-" else 0
files.append({"path": path, "lines": n})
total += n
if not matches(path, allow):
violations.append({"kind": "not_allowlisted", "path": path})
files.sort(key=lambda f: f["path"])

max_lines, max_files = budget.get("max_lines"), budget.get("max_files")
if max_lines is not None and total > max_lines:
violations.append({"kind": "over_line_budget", "lines": total, "max": max_lines})
if max_files is not None and len(files) > max_files:
violations.append({"kind": "over_file_budget", "files": len(files), "max": max_files})
violations += local_dependency_violations(root)
return {
"ok": not violations,
"upstream": tag,
"files": files,
"total_lines": total,
"violations": violations,
}


def main():
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--root", default=None, help="repo root (default: parent of local/)")
args = ap.parse_args()
root = args.root or Path(__file__).resolve().parents[2]
try:
result = check(root)
except (RuntimeError, OSError, tomllib.TOMLDecodeError) as e:
print(json.dumps({"ok": False, "error": str(e), "violations": []}))
return 2
print(json.dumps(result))
return 0 if result["ok"] else 1


if __name__ == "__main__":
sys.exit(main())
67 changes: 67 additions & 0 deletions local/scripts/test_check_fork_surface.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import json
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path

SCRIPT = Path(__file__).with_name("check_fork_surface.py")
TOML = 'allow = ["up/hook.rs"]\ngenerated = ["up/Cargo.lock"]\n[budget]\nmax_lines = 5\nmax_files = 2\n'


def git(root, *a):
subprocess.run(["git", "-C", str(root), "-c", "user.name=t", "-c", "user.email=t@t",
"-c", "commit.gpgsign=false", *a], check=True, capture_output=True)


class CheckForkSurface(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.root = Path(self.tmp.name)
git(self.root, "init", "-q")
self.write("local/UPSTREAM_VERSION", "v1\n")
self.write("local/fork-surface.toml", TOML)
self.write("up/hook.rs", "a\n")
self.write("up/other.rs", "a\n")
git(self.root, "add", "-A")
git(self.root, "commit", "-qm", "base")
git(self.root, "tag", "v1")

def tearDown(self):
self.tmp.cleanup()

def write(self, rel, text):
p = self.root / rel
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(text)

def run_check(self):
git(self.root, "add", "-A")
git(self.root, "commit", "-qm", "change")
r = subprocess.run([sys.executable, str(SCRIPT), "--root", str(self.root)],
capture_output=True, text=True)
return r.returncode, json.loads(r.stdout)

def test_ok(self):
self.write("up/hook.rs", "a\nb\n")
self.write("up/Cargo.lock", "x\n" * 100) # generated: not budgeted
self.write("codex-rs/local/x/lib.rs", "x\n" * 100) # fork-owned
code, out = self.run_check()
self.assertEqual((code, out["ok"], out["total_lines"]), (0, True, 1))
self.assertEqual([f["path"] for f in out["files"]], ["up/hook.rs"])

def test_non_allowlisted_path(self):
self.write("up/other.rs", "a\nb\n")
code, out = self.run_check()
self.assertEqual(code, 1)
self.assertEqual(out["violations"], [{"kind": "not_allowlisted", "path": "up/other.rs"}])

def test_over_budget(self):
self.write("up/hook.rs", "a\n" + "b\n" * 9)
code, out = self.run_check()
self.assertEqual(code, 1)
self.assertEqual([v["kind"] for v in out["violations"]], ["over_line_budget"])


if __name__ == "__main__":
unittest.main()