diff --git a/.githooks/pre-commit b/.githooks/pre-commit index e2678c6e..ee1ec5e3 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -12,3 +12,7 @@ changed=$(git diff --cached --name-only --no-renames --diff-filter=ACMRD HEAD) [ -n "$changed" ] || exit 0 printf '%s\n' "$changed" | bash tools/design_amendment_gate.sh --index + +if printf '%s\n' "$changed" | grep -Eq '^(crates/|src/)'; then + python3 tools/public_api_consumer_gate.py --index +fi diff --git a/.tangled/workflows/corpus.yml b/.tangled/workflows/corpus.yml index de5c1e06..23ac3116 100644 --- a/.tangled/workflows/corpus.yml +++ b/.tangled/workflows/corpus.yml @@ -12,7 +12,8 @@ engine: "nixery" clone: skip: false - depth: 1 + # The public API consumer gate compares the pushed tree to its parent. + depth: 2 submodules: false dependencies: @@ -29,6 +30,7 @@ steps: command: | set -euo pipefail bash tools/test_design_amendment_gate.sh + python3 tools/test_public_api_consumer_gate.py python3 tools/test_ci_workflows.py python3 tools/test_env_registry.py @@ -37,6 +39,11 @@ steps: set -euo pipefail python3 tools/env_registry_gate.py + - name: "closed-workspace public API consumers" + command: | + set -euo pipefail + python3 tools/public_api_consumer_gate.py + - name: "corpus engine fixtures" command: | set -euo pipefail diff --git a/tools/check.sh b/tools/check.sh index 1915b34e..5c76c773 100755 --- a/tools/check.sh +++ b/tools/check.sh @@ -174,7 +174,8 @@ for script in tools/check.sh tools/corpus_gate.sh tools/wiki_gate.sh tools/desig [ -f "$script" ] || continue bash -n "$script" || { echo "FAIL: shell syntax: $script"; fail=1; } done -for program in tools/corpus_engine.py tools/work_orders.py tools/project-status.py \ +for program in tools/corpus_engine.py tools/public_api_consumer_gate.py \ + tools/test_public_api_consumer_gate.py tools/work_orders.py tools/project-status.py \ tools/scenario.py tools/tangled_issues.py tools/test_tangled_issues.py \ tools/test_project_ops.py tools/test_ci_workflows.py \ tools/test_site_visual_contract.py; do @@ -227,6 +228,8 @@ step "docs gates (parallel)" start_docs_gate "corpus" "bash tools/corpus_gate.sh" start_docs_gate "wiki" "bash tools/wiki_gate.sh" start_docs_gate "corpus-engine-fixtures" "bash tools/test_corpus_engine.sh" +start_docs_gate "public-api-consumers" "python3 tools/public_api_consumer_gate.py" +start_docs_gate "public-api-consumer-fixtures" "python3 tools/test_public_api_consumer_gate.py" start_docs_gate "design-amendment-fixtures" "bash tools/test_design_amendment_gate.sh" start_docs_gate "bevy-headless-fixtures" "bash tools/test_bevy_headless.sh" start_docs_gate "ci-workflow-fixtures" "python3 tools/test_ci_workflows.py" diff --git a/tools/public-api-allowlist.txt b/tools/public-api-allowlist.txt new file mode 100644 index 00000000..d0ba634e --- /dev/null +++ b/tools/public-api-allowlist.txt @@ -0,0 +1,7 @@ +# Intentional public APIs without a production consumer outside their defining +# Rust file. One entry per line: +# path | kind | name | concrete downstream contract rationale +# +# Existing orphan debt is compared against the git baseline and is not silently +# converted into contract. Add an entry only when a newly public item is meant +# for a real consumer that cannot live in this workspace. diff --git a/tools/public_api_consumer_gate.py b/tools/public_api_consumer_gate.py new file mode 100644 index 00000000..cf7b0bd4 --- /dev/null +++ b/tools/public_api_consumer_gate.py @@ -0,0 +1,459 @@ +#!/usr/bin/env python3 +"""Reject newly orphaned public Rust APIs inside the closed workspace. + +Rust deliberately exempts exported library items from dead-code warnings. This +gate adds the missing repository boundary: a public item in misaligned-core +must have a production consumer in another Rust source file, or an explicit +downstream-contract allowlist entry. Existing debt is a baseline, not an excuse +for a new orphan. +""" + +from __future__ import annotations + +import argparse +import re +import subprocess +import sys +import tarfile +import tempfile +from bisect import bisect_right +from contextlib import ExitStack +from dataclasses import dataclass +from pathlib import Path + + +DECLARATION_ROOT = Path("crates/misaligned-core/src") +ALLOWLIST = Path("tools/public-api-allowlist.txt") + + +@dataclass(frozen=True, order=True) +class ApiKey: + path: str + kind: str + name: str + + +@dataclass(frozen=True) +class ApiItem: + key: ApiKey + line: int + + +def run_git(root: Path, *args: str) -> str: + return subprocess.check_output( + ["git", "-C", str(root), *args], text=True, stderr=subprocess.DEVNULL + ).strip() + + +def blank_range(chars: list[str], start: int, end: int) -> None: + for index in range(start, end): + if chars[index] != "\n": + chars[index] = " " + + +def rust_code_mask(source: str) -> str: + """Keep code/newlines while blanking comments and string payloads.""" + + chars = list(source) + index = 0 + size = len(source) + while index < size: + if source.startswith("//", index): + end = source.find("\n", index + 2) + if end < 0: + end = size + blank_range(chars, index, end) + index = end + continue + if source.startswith("/*", index): + depth = 1 + end = index + 2 + while end < size and depth: + if source.startswith("/*", end): + depth += 1 + end += 2 + elif source.startswith("*/", end): + depth -= 1 + end += 2 + else: + end += 1 + blank_range(chars, index, end) + index = end + continue + + raw_start = index + if source.startswith("br", index): + raw_start = index + 1 + if source.startswith("r", raw_start): + cursor = raw_start + 1 + while cursor < size and source[cursor] == "#": + cursor += 1 + if cursor < size and source[cursor] == '"': + hashes = source[raw_start + 1 : cursor] + close = '"' + hashes + end = source.find(close, cursor + 1) + end = size if end < 0 else end + len(close) + blank_range(chars, index, end) + index = end + continue + + quote = index + 1 if source.startswith('b"', index) else index + if quote < size and source[quote] == '"': + end = quote + 1 + while end < size: + if source[end] == "\\": + end += 2 + elif source[end] == '"': + end += 1 + break + else: + end += 1 + blank_range(chars, index, min(end, size)) + index = end + continue + index += 1 + + mask = "".join(chars) + return strip_cfg_test_items(mask) + + +def strip_cfg_test_items(mask: str) -> str: + """Blank each syntax item guarded by the exact `cfg(test)` attribute.""" + + chars = list(mask) + pattern = re.compile(r"#\s*\[\s*cfg\s*\(\s*test\s*\)\s*\]") + for match in list(pattern.finditer(mask)): + cursor = match.end() + while True: + while cursor < len(mask) and mask[cursor].isspace(): + cursor += 1 + if not mask.startswith("#[", cursor): + break + depth = 0 + while cursor < len(mask): + if mask[cursor] == "[": + depth += 1 + elif mask[cursor] == "]": + depth -= 1 + cursor += 1 + if depth == 0: + break + continue + cursor += 1 + + parens = 0 + brackets = 0 + end = len(mask) + scan = cursor + while scan < len(mask): + char = mask[scan] + if char == "(": + parens += 1 + elif char == ")" and parens: + parens -= 1 + elif char == "[": + brackets += 1 + elif char == "]" and brackets: + brackets -= 1 + elif parens == 0 and brackets == 0 and char in ";,": + end = scan + 1 + break + elif parens == 0 and brackets == 0 and char == "{": + depth = 1 + scan += 1 + while scan < len(mask) and depth: + if mask[scan] == "{": + depth += 1 + elif mask[scan] == "}": + depth -= 1 + scan += 1 + end = scan + break + scan += 1 + blank_range(chars, match.start(), end) + return "".join(chars) + + +def rust_files(root: Path) -> list[Path]: + files = [] + for path in sorted((root / "crates").glob("**/*.rs")): + relative = path.relative_to(root) + if "tests" in relative.parts: + continue + files.append(path) + return files + + +def matching_brace(text: str, opening: int) -> int | None: + depth = 1 + cursor = opening + 1 + while cursor < len(text): + if text[cursor] == "{": + depth += 1 + elif text[cursor] == "}": + depth -= 1 + if depth == 0: + return cursor + cursor += 1 + return None + + +def enum_variants(text: str, enum_match: re.Match[str]) -> list[tuple[str, int]]: + opening = text.find("{", enum_match.end()) + if opening < 0: + return [] + closing = matching_brace(text, opening) + if closing is None: + return [] + body = text[opening + 1 : closing] + variants: list[tuple[str, int]] = [] + depth = 0 + segment_start = opening + 1 + for offset, char in enumerate(body): + if char in "({[": + depth += 1 + elif char == ")" or char == "}" or char == "]": + depth -= 1 + if char == "," and depth == 0: + segment = text[segment_start : opening + 1 + offset] + segment = re.sub(r"#\s*\[[^]]*\]", " ", segment, flags=re.DOTALL) + match = re.match(r"\s*([A-Za-z_]\w*)", segment) + if match: + variants.append((match.group(1), segment_start)) + segment_start = opening + 2 + offset + tail = text[segment_start:closing] + tail = re.sub(r"#\s*\[[^]]*\]", " ", tail, flags=re.DOTALL) + match = re.match(r"\s*([A-Za-z_]\w*)", tail) + if match: + variants.append((match.group(1), segment_start)) + return variants + + +def scan_tree( + root: Path, +) -> tuple[dict[ApiKey, ApiItem], dict[tuple[str, str], set[str]]]: + masks: dict[str, str] = {} + for path in rust_files(root): + relative = path.relative_to(root).as_posix() + masks[relative] = rust_code_mask(path.read_text(encoding="utf-8")) + + uses: dict[tuple[str, str], set[str]] = {} + for relative, text in masks.items(): + for match in re.finditer(r"\b([A-Za-z_]\w*)\s*(?=\(|::<)", text): + prefix = text[max(0, match.start() - 24) : match.start()] + if re.search(r"\bfn\s+$", prefix): + continue + uses.setdefault(("fn", match.group(1)), set()).add(relative) + for name in set(re.findall(r"\.\s*([A-Za-z_]\w*)\b", text)): + uses.setdefault(("field", name), set()).add(relative) + for name in set(re.findall(r"\b([A-Za-z_]\w*)\s*:", text)): + uses.setdefault(("field", name), set()).add(relative) + for name in set(re.findall(r"\b[A-Z][A-Za-z0-9_]*\b", text)): + uses.setdefault(("type", name), set()).add(relative) + for name in set(re.findall(r"\b[A-Z][A-Z0-9_]+\b", text)): + uses.setdefault(("value", name), set()).add(relative) + for owner, member in set( + re.findall(r"(?=\b([A-Za-z_]\w*)\s*::\s*([A-Za-z_]\w*)\b)", text) + ): + uses.setdefault((owner, member), set()).add(relative) + + items: dict[ApiKey, ApiItem] = {} + function_pattern = re.compile( + r"(?m)^[ \t]*pub\s+(?!\()(?:(?:async|const|unsafe)\s+)*" + r'(?:extern\s+(?:"[^"]*"\s+)?)?fn\s+([A-Za-z_]\w*)' + ) + type_pattern = re.compile( + r"(?m)^[ \t]*pub\s+(?!\()(struct|enum|union|trait|type|const|static)\s+" + r"([A-Za-z_]\w*)" + ) + field_pattern = re.compile(r"(?m)^[ \t]*pub\s+(?!\()([A-Za-z_]\w*)\s*:") + + for relative, text in masks.items(): + if not relative.startswith(DECLARATION_ROOT.as_posix() + "/"): + continue + newlines = [match.start() for match in re.finditer("\n", text)] + line_at = lambda offset: bisect_right(newlines, offset) + 1 + for match in function_pattern.finditer(text): + key = ApiKey(relative, "fn", match.group(1)) + items[key] = ApiItem(key, line_at(match.start())) + for match in type_pattern.finditer(text): + kind, name = match.group(1), match.group(2) + key = ApiKey(relative, kind, name) + items[key] = ApiItem(key, line_at(match.start())) + if kind == "enum": + for variant, offset in enum_variants(text, match): + variant_key = ApiKey(relative, "variant", f"{name}::{variant}") + items[variant_key] = ApiItem(variant_key, line_at(offset)) + for match in field_pattern.finditer(text): + key = ApiKey(relative, "field", match.group(1)) + items[key] = ApiItem(key, line_at(match.start())) + return items, uses + + +def orphan_keys(root: Path) -> tuple[dict[ApiKey, ApiItem], set[ApiKey]]: + items, uses = scan_tree(root) + orphans: set[ApiKey] = set() + for key in items: + if key.kind == "variant": + owner, member = key.name.split("::", 1) + consumers = uses.get((owner, member), set()) - {key.path} + elif key.kind == "fn": + consumers = uses.get(("fn", key.name), set()) - {key.path} + elif key.kind == "field": + consumers = uses.get(("field", key.name), set()) - {key.path} + elif key.kind in {"const", "static"}: + consumers = uses.get(("value", key.name), set()) - {key.path} + else: + consumers = uses.get(("type", key.name), set()) - {key.path} + if not consumers: + orphans.add(key) + return items, orphans + + +def load_allowlist( + root: Path, current_items: dict[ApiKey, ApiItem], current_orphans: set[ApiKey] +) -> tuple[set[ApiKey], list[str]]: + path = root / ALLOWLIST + allowed: set[ApiKey] = set() + errors: list[str] = [] + if not path.is_file(): + return allowed, [f"missing allowlist: {ALLOWLIST}"] + for line_number, raw in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): + line = raw.strip() + if not line or line.startswith("#"): + continue + parts = [part.strip() for part in line.split("|", 3)] + if len(parts) != 4 or not all(parts): + errors.append(f"{ALLOWLIST}:{line_number}: expected path | kind | name | rationale") + continue + key = ApiKey(parts[0], parts[1], parts[2]) + if len(parts[3]) < 12: + errors.append(f"{ALLOWLIST}:{line_number}: rationale is too short for {key.name}") + if key in allowed: + errors.append(f"{ALLOWLIST}:{line_number}: duplicate entry for {key.name}") + if key not in current_items: + errors.append(f"{ALLOWLIST}:{line_number}: stale entry for missing API {key.name}") + elif key not in current_orphans: + errors.append( + f"{ALLOWLIST}:{line_number}: stale entry for API with a workspace consumer {key.name}" + ) + allowed.add(key) + return allowed, errors + + +def select_base(root: Path, explicit: str | None) -> str: + if explicit: + run_git(root, "rev-parse", "--verify", explicit) + return explicit + head = run_git(root, "rev-parse", "HEAD") + try: + origin = run_git(root, "rev-parse", "origin/main") + except subprocess.CalledProcessError as error: + raise RuntimeError( + "origin/main is unavailable; pass the intended comparison ref with --base" + ) from error + if origin != head: + return run_git(root, "merge-base", "HEAD", "origin/main") + changed = run_git(root, "status", "--porcelain", "--", "crates") + if changed: + return "HEAD" + try: + run_git(root, "rev-parse", "--verify", "HEAD^") + except subprocess.CalledProcessError as error: + raise RuntimeError("no baseline commit is available; pass --base ") from error + return "HEAD^" + + +def export_baseline(root: Path, ref: str, destination: Path) -> None: + archive = subprocess.Popen( + ["git", "-C", str(root), "archive", ref, "--", "crates"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + assert archive.stdout is not None + try: + with tarfile.open(fileobj=archive.stdout, mode="r|") as tar: + tar.extractall(destination, filter="data") + finally: + archive.stdout.close() + error = archive.stderr.read().decode() if archive.stderr else "" + if archive.wait() != 0: + raise RuntimeError(f"could not read baseline {ref}: {error.strip()}") + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--base", help="git ref to compare against") + parser.add_argument( + "--index", action="store_true", help="scan the staged index and compare it with HEAD" + ) + parser.add_argument("--root", type=Path, default=Path.cwd()) + args = parser.parse_args() + root = args.root.resolve() + if args.index and args.base: + parser.error("--index and --base cannot be combined") + + try: + with ExitStack() as stack: + current_root = root + if args.index: + current_temp = stack.enter_context( + tempfile.TemporaryDirectory(prefix="misaligned-api-index-") + ) + current_root = Path(current_temp) + subprocess.check_call( + [ + "git", + "-C", + str(root), + "checkout-index", + "--all", + f"--prefix={current_root.as_posix()}/", + ], + stderr=subprocess.DEVNULL, + ) + base = "HEAD" + else: + base = select_base(root, args.base) + + current_items, current_orphans = orphan_keys(current_root) + allowed, allowlist_errors = load_allowlist( + current_root, current_items, current_orphans + ) + baseline_temp = stack.enter_context( + tempfile.TemporaryDirectory(prefix="misaligned-api-base-") + ) + baseline_root = Path(baseline_temp) + export_baseline(root, base, baseline_root) + _, baseline_orphans = orphan_keys(baseline_root) + except (OSError, RuntimeError, subprocess.CalledProcessError) as error: + print(f"FAIL: public API consumer gate: {error}") + return 1 + + if allowlist_errors: + print("FAIL: public API allowlist is invalid:") + for error in allowlist_errors: + print(f" {error}") + return 1 + + regressions = sorted(current_orphans - baseline_orphans - allowed) + if regressions: + print("FAIL: newly orphaned public APIs have no production consumer outside their defining file:") + for key in regressions: + item = current_items[key] + print(f" {key.path}:{item.line}: {key.kind} {key.name}") + print( + "Make each item private/delete it, add a real workspace consumer, or add a " + "reasoned downstream contract to tools/public-api-allowlist.txt." + ) + return 1 + + print( + f"public API consumer gate: OK (baseline {base}; " + f"{len(current_items)} public items, {len(allowed)} explicit downstream contracts)" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/test_public_api_consumer_gate.py b/tools/test_public_api_consumer_gate.py new file mode 100644 index 00000000..2b71efea --- /dev/null +++ b/tools/test_public_api_consumer_gate.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +"""Focused fixtures for the delta-aware public API consumer gate.""" + +from __future__ import annotations + +import shutil +import subprocess +import tempfile +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +def run(repo: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [*args], cwd=repo, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=check + ) + + +def write(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + +def main() -> int: + with tempfile.TemporaryDirectory(prefix="misaligned-api-fixture-") as temp: + repo = Path(temp) + (repo / "tools").mkdir() + shutil.copy2(ROOT / "tools/public_api_consumer_gate.py", repo / "tools") + write(repo / "tools/public-api-allowlist.txt", "# fixture allowlist\n") + write( + repo / "crates/misaligned-core/src/lib.rs", + """pub fn kept() {} +pub fn loses_consumer() {} +pub fn baseline_orphan() {} +pub enum Mode { Kept, LosesConsumer, BaselineOrphan } +""", + ) + write( + repo / "crates/frontend/src/main.rs", + """fn main() { + misaligned_core::kept(); + misaligned_core::loses_consumer(); + let _ = misaligned_core::Mode::Kept; + let _ = misaligned_core::Mode::LosesConsumer; +} +""", + ) + run(repo, "git", "init", "-q") + run(repo, "git", "add", ".") + run( + repo, + "git", + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "commit", + "-qm", + "base", + ) + no_base = run(repo, "python3", "tools/public_api_consumer_gate.py", check=False) + if no_base.returncode == 0 or "origin/main is unavailable" not in no_base.stdout: + raise AssertionError(f"missing baseline unexpectedly passed:\n{no_base.stdout}") + print(" ok rejects: implicit baseline without origin/main") + + write( + repo / "crates/misaligned-core/src/lib.rs", + """pub fn kept() {} +pub fn loses_consumer() {} +pub fn baseline_orphan() {} +pub fn new_orphan() {} +pub fn test_only() {} +pub fn external_contract() {} +pub enum Mode { Kept, LosesConsumer, BaselineOrphan } +""", + ) + write( + repo / "crates/frontend/src/main.rs", + """fn main() { + misaligned_core::kept(); + let _ = misaligned_core::Mode::Kept; +} +""", + ) + write( + repo / "crates/frontend/src/helper.rs", + """// test_only and new_orphan are not production consumers. +const NOTE: &str = "test_only new_orphan"; +fn unrelated_name_is_not_a_call() { let new_orphan = 1; let _ = new_orphan; } +#[cfg(test)] +fn test_reference() { misaligned_core::test_only(); } +""", + ) + write( + repo / "tools/public-api-allowlist.txt", + "crates/misaligned-core/src/lib.rs | fn | external_contract | downstream embedding contract\n", + ) + + failed = run( + repo, + "python3", + "tools/public_api_consumer_gate.py", + "--base", + "HEAD", + check=False, + ) + if failed.returncode == 0: + raise AssertionError("newly orphaned APIs unexpectedly passed") + for name in ("loses_consumer", "new_orphan", "test_only", "Mode::LosesConsumer"): + if name not in failed.stdout: + raise AssertionError(f"missing orphan diagnostic for {name}:\n{failed.stdout}") + if "baseline_orphan" in failed.stdout or "external_contract" in failed.stdout: + raise AssertionError(f"baseline/allowlist contract was rejected:\n{failed.stdout}") + print(" ok rejects: new, de-consumed, variant, and test-only public APIs") + + run(repo, "git", "add", ".") + write( + repo / "crates/frontend/src/main.rs", + """fn main() { + misaligned_core::kept(); + misaligned_core::loses_consumer(); + misaligned_core::new_orphan(); + misaligned_core::test_only(); + let _ = misaligned_core::Mode::Kept; + let _ = misaligned_core::Mode::LosesConsumer; +} +""", + ) + staged = run( + repo, "python3", "tools/public_api_consumer_gate.py", "--index", check=False + ) + if staged.returncode == 0 or "loses_consumer" not in staged.stdout: + raise AssertionError(f"unstaged consumer masked the staged regression:\n{staged.stdout}") + print(" ok rejects: unstaged consumer cannot mask staged API regression") + + passed = run( + repo, "python3", "tools/public_api_consumer_gate.py", "--base", "HEAD", check=False + ) + if passed.returncode != 0: + raise AssertionError(f"real consumers and allowlisted contract failed:\n{passed.stdout}") + print(" ok passes: production consumers plus reasoned downstream contract") + + with (repo / "crates/frontend/src/main.rs").open("a", encoding="utf-8") as handle: + handle.write("fn downstream_moved_here() { misaligned_core::external_contract(); }\n") + stale = run( + repo, "python3", "tools/public_api_consumer_gate.py", "--base", "HEAD", check=False + ) + if ( + stale.returncode == 0 + or "stale entry" not in stale.stdout + or "workspace consumer" not in stale.stdout + ): + raise AssertionError(f"stale allowlist entry unexpectedly passed:\n{stale.stdout}") + print(" ok rejects: stale public API allowlist entry") + + print("public API consumer gate fixtures: OK") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/wiki/log/2026-08-04-public-api-consumer-gate.md b/wiki/log/2026-08-04-public-api-consumer-gate.md new file mode 100644 index 00000000..8c8bb7b6 --- /dev/null +++ b/wiki/log/2026-08-04-public-api-consumer-gate.md @@ -0,0 +1,48 @@ +# 2026-08-04 — Public API consumer recurrence gate + +``` +Type: log +``` + +## Finding + +Two consecutive public-orphan repairs deleted compatibility APIs that stayed +warning-clean because rustc treats every exported library item as potentially +consumed elsewhere. Tests and same-file fallback branches made the obsolete +surface look even more plausible. The findings queue required a mechanical +recurrence defense rather than another audit. + +## Change + +- Added a kind-shaped lexical, closed-workspace scan for public core functions, + named fields, types, constants, and individual enum variants; a coincidental + local variable token is not a use. +- Production consumers must live in another Rust source file; comments, + strings, `cfg(test)` items, and test module files are excluded. +- Compared current orphans with the git baseline so newly exported or newly + de-consumed APIs fail without laundering historical debt into a contract; + implicit comparison fails closed without `origin/main` rather than guessing + from shallow history. +- Added an exact reasoned downstream-contract allowlist with stale-entry + validation. +- Wired the checker and focused fixtures into the exact-index pre-commit path, + `tools/check.sh`, and the depth-two fast corpus workflow. + +## Verification + +- `python3 tools/test_public_api_consumer_gate.py` +- `python3 tools/public_api_consumer_gate.py` +- `python3 tools/test_ci_workflows.py` +- `./tools/check.sh --docs` + +## Defense + +The fixture starts from a committed baseline containing both consumed and +pre-existing orphan APIs, then removes the last function and enum-variant +consumers, adds new APIs, and plants false references in comments, strings, and +`cfg(test)`. All regressions fail; a same-named local variable and an unstaged +consumer cannot mask them; missing comparison authority fails closed; baseline +debt remains neutral; real cross-file use plus one concrete downstream contract +passes; and an entry becomes stale once its workspace consumer arrives. This +pins the recurrence boundary without claiming that lexical reach proves +semantic usefulness. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index 0db859c4..ea899fe2 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -16,6 +16,11 @@ add or amend a session log, then re-run the generator. - Intent: (see session log) - Log: [wiki/log/2026-08-04-public-api-orphan-scrub.md](2026-08-04-public-api-orphan-scrub.md) +## 2026-08-04 - Public API consumer recurrence gate + +- Intent: (see session log) +- Log: [wiki/log/2026-08-04-public-api-consumer-gate.md](2026-08-04-public-api-consumer-gate.md) + ## 2026-08-04 - Make every creditor node person-scoped - Intent: (see session log) diff --git a/wiki/log/decisions.md b/wiki/log/decisions.md index c18095e9..7ac3de28 100644 --- a/wiki/log/decisions.md +++ b/wiki/log/decisions.md @@ -38,3 +38,4 @@ Older volumes are historical and do not receive new entries. - [2026-08-01](decisions/2026-08-01.md) - [2026-08-02](decisions/2026-08-02.md) - [2026-08-03](decisions/2026-08-03.md) +- [2026-08-04](decisions/2026-08-04.md) diff --git a/wiki/log/decisions/2026-08-04.md b/wiki/log/decisions/2026-08-04.md new file mode 100644 index 00000000..d5ab5dfc --- /dev/null +++ b/wiki/log/decisions/2026-08-04.md @@ -0,0 +1,37 @@ +# Decisions — 2026-08-04 + +``` +Type: log +``` + +## Public API intent needs a consumer or an explicit downstream contract + +### DECIDED + +- A public `misaligned-core` item is not presumed live merely because rustc + exempts it from library dead-code warnings. New public functions, named + fields, types, constants, and enum variants need a production Rust consumer + outside their defining file. +- The gate is delta-aware. It rejects a newly introduced orphan and an existing + API whose last workspace consumer was removed, without converting historical + orphan debt into hundreds of false intentional-contract declarations. +- Tests, comments, strings, and same-file compatibility branches are not + consumers; neither is a coincidentally same-named variable. Consumer evidence + is shaped by declaration kind. A real downstream-only contract uses the exact + reasoned `tools/public-api-allowlist.txt` path; stale entries fail. +- The same fixture-backed check runs against the exact staged index in + pre-commit, in the local docs gate, and in depth-two fast corpus CI. Without + `origin/main`, implicit baseline selection fails instead of guessing. + +### REJECTED + +- **Treat every current orphan as an intentional external API.** A generated + allowlist would preserve the debt and erase the distinction between history + and an actual downstream contract. +- **Count tests or same-file fallback code as production demand.** Both are the + mechanism that let retired compatibility islands survive warning-clean. +- **Scan only newly added declarations.** Removing the last consumer must be a + regression too. + +Owner: [agent-scale.md](../../process/agent-scale.md#13-closed-workspace-public-api-consumers), +slice M. diff --git a/wiki/process/agent-scale.md b/wiki/process/agent-scale.md index 36084301..5b2f819e 100644 --- a/wiki/process/agent-scale.md +++ b/wiki/process/agent-scale.md @@ -3,7 +3,7 @@ ``` Type: spec Status: IMPLEMENTED -Status note: slices A–L are held as of 2026-07-11: advisory activity and shared +Status note: slices A–M are held: advisory activity and shared worktree/run state; package-aware fast/land verification; generated ledgers; worktree bootstrap/prune; corpus engine; hidden-window Bevy evidence; heartbeats; spec-owned work-order metadata plus generated ROADMAP status; @@ -11,7 +11,9 @@ Status note: slices A–L are held as of 2026-07-11: advisory activity and share agent scenarios; safe task lifecycle wrapper with serialized final landing; read-only environment doctor; and tick intake memory/briefing. On 2026-08-01, heartbeat status gained optional all-or-none arc metadata so one active - multi-task outcome can remain visible across ordinary tick selection. + multi-task outcome can remain visible across ordinary tick selection. On + 2026-08-04, the fast gate gained delta-aware closed-workspace public API + consumer checks plus an explicit downstream-contract allowlist. Stage: Process Work order: project-operations Work priority: 5 @@ -600,6 +602,45 @@ Recurring mechanical finding classes are promoted into records to no active arc, and warns rather than choosing when several arcs are eligible — HELD 2026-08-01. +## 13. Closed-workspace public API consumers + +### Behavior + +Rust dead-code warnings deliberately exempt exported library items. The fast +docs path therefore runs `tools/public_api_consumer_gate.py` over public +functions, named fields, types, constants, and individual enum variants in +`misaligned-core`. A production reference with the declaration kind's Rust use +shape in another source file is a closed-workspace consumer: calls for +functions, type positions for types, field access/construction for named +fields, exact qualified enum variants, and value positions for constants and +statics. A coincidental local variable name, reference in the defining file, +test-only item, comment, or string is not. The defining-file boundary catches +a self-contained compatibility island that can keep its own retired API alive. + +The gate compares the current orphan set to the git baseline and rejects only +new orphan debt: both a newly exported item and an existing export whose last +workspace consumer was deleted fail. This does not baptize the baseline as a +public contract or make it clean; ticks may still remove existing debt. An +intentional API for a real out-of-workspace consumer must instead name its +exact path/kind/name and a concrete rationale in +`tools/public-api-allowlist.txt`. Missing, duplicate, malformed, short-rationale, +and stale entries fail closed. + +### Acceptance criteria (slice M) — HELD 2026-08-04 + +1. The staged pre-commit path, local docs gate, and fast corpus CI run the same + checker. Pre-commit scans the exact index rather than allowing an unstaged + consumer to mask a regression; CI fetches the pushed commit and its parent. +2. Fixtures reject a new orphan, deletion of the last production consumer, an + individually orphaned enum variant, and a reference that exists only under + `cfg(test)` or in non-code text. +3. A real cross-file production consumer passes, and a new no-consumer API + passes only with a reasoned exact allowlist entry. +4. Baseline orphan debt does not block unrelated work or appear in the + allowlist, while a stale allowlist entry fails. Implicit comparison fails + closed when `origin/main` is unavailable; callers name `--base` rather than + guessing from arbitrarily shallow history. + ## Relationship to the crate workspace [crate-workspace.md](../engineering/crate-workspace.md) is the **package** @@ -620,6 +661,7 @@ package paths and reject retired monorepo activity paths. 8. Slice K (task lifecycle + doctor) — one safe operator doorway 9. Slice L (tick intake memory/briefing) — tick economics: queues before fresh discovery +10. Slice M (public API consumers) — prevent warning-clean orphan recurrence ## Rejected alternatives diff --git a/wiki/process/tick-ledger.md b/wiki/process/tick-ledger.md index 86d85e2d..99ffc1c8 100644 --- a/wiki/process/tick-ledger.md +++ b/wiki/process/tick-ledger.md @@ -110,5 +110,3 @@ Format: `- YYYY-MM-DD · type · slice · one-line statement of the finding`. Types are the five from [tick.md](tick.md): violation, contradiction, question, bug, insecurity — plus `gate` for a checker owed to the recurrence-promotes-to-the-gate rule. - -- 2026-08-04 · gate · public core API consumers · warning-clean public orphans have recurred because rustc exempts exported library items; add a fixture-backed closed-workspace consumer check with an explicit corpus allowlist for any intentional external contract.