diff --git a/tools/public_api_consumer_gate.py b/tools/public_api_consumer_gate.py index cf7b0bd4..feafd103 100644 --- a/tools/public_api_consumer_gate.py +++ b/tools/public_api_consumer_gate.py @@ -198,6 +198,121 @@ def matching_brace(text: str, opening: int) -> int | None: return None +def matching_angle(text: str, opening: int) -> int | None: + depth = 1 + cursor = opening + 1 + while cursor < len(text): + if text[cursor] == "<": + depth += 1 + elif text[cursor] == ">" and text[cursor - 1] != "-": + depth -= 1 + if depth == 0: + return cursor + cursor += 1 + return None + + +def local_declarations(text: str) -> dict[str, set[str]]: + """Names defined by this file cannot prove a same-named core use. + + This is deliberately conservative. A file that owns a colliding helper, + type, value, or named field is ambiguous to the lexical gate, even if it + also uses the core item. Put an unambiguous consumer in another file rather + than letting local code accidentally keep a public API alive. + """ + + declarations = {kind: set() for kind in ("fn", "type", "value", "field")} + visibility = r"(?:pub(?:\s*\([^)]*\))?\s+)?" + qualifiers = r"(?:(?:async|const|unsafe)\s+)*(?:extern\s+)?" + for match in re.finditer( + rf"(?m)^[ \t]*{visibility}{qualifiers}fn\s+([A-Za-z_]\w*)", text + ): + declarations["fn"].add(match.group(1)) + type_pattern = re.compile( + rf"(?m)^[ \t]*{visibility}(struct|enum|union|trait|type)\s+([A-Za-z_]\w*)" + ) + for match in type_pattern.finditer(text): + declarations["type"].add(match.group(2)) + if match.group(1) == "enum": + for variant, _ in enum_variants(text, match): + declarations["value"].add(variant) + if match.group(1) not in {"struct", "union"}: + continue + opening = text.find("{", match.end()) + terminator = text.find(";", match.end()) + if opening < 0 or (terminator >= 0 and terminator < opening): + continue + closing = matching_brace(text, opening) + if closing is None: + continue + body = text[opening + 1 : closing] + depth = 0 + segment_start = opening + 1 + segments: list[str] = [] + for offset, char in enumerate(body): + if char in "({[": + depth += 1 + elif char in ")}]": + depth -= 1 + if char == "," and depth == 0: + segments.append(text[segment_start : opening + 1 + offset]) + segment_start = opening + 2 + offset + segments.append(text[segment_start:closing]) + for segment in segments: + segment = re.sub(r"#\s*\[[^]]*\]", " ", segment, flags=re.DOTALL) + field = re.match( + rf"\s*{visibility}([A-Za-z_]\w*)\s*:", segment + ) + if field: + declarations["field"].add(field.group(1)) + for match in re.finditer( + rf"(?m)^[ \t]*{visibility}(?:const|static(?:\s+mut)?)\s+([A-Za-z_]\w*)", text + ): + declarations["value"].add(match.group(1)) + generic_pattern = re.compile( + r"\b(?:(?:fn|struct|enum|union|trait|type)\s+[A-Za-z_]\w*|impl)\s*<" + ) + for match in generic_pattern.finditer(text): + opening = text.rfind("<", match.start(), match.end()) + closing = matching_angle(text, opening) + if closing is None: + continue + body = text[opening + 1 : closing] + depth = 0 + segment_start = 0 + segments: list[str] = [] + for offset, char in enumerate(body): + if char in "<({[": + depth += 1 + elif char in ">)}]": + depth -= 1 + if char == "," and depth == 0: + segments.append(body[segment_start:offset]) + segment_start = offset + 1 + segments.append(body[segment_start:]) + for segment in segments: + segment = re.sub(r"#\s*\[[^]]*\]", " ", segment, flags=re.DOTALL).strip() + if not segment or segment.startswith("'"): + continue + const_parameter = re.match(r"const\s+([A-Za-z_]\w*)", segment) + if const_parameter: + name = const_parameter.group(1) + declarations["type"].add(name) + declarations["value"].add(name) + continue + type_parameter = re.match(r"([A-Za-z_]\w*)", segment) + if type_parameter: + declarations["type"].add(type_parameter.group(1)) + for match in re.finditer(r"\b(?:macro_rules!|macro)\s*([A-Za-z_]\w*)", text): + declarations["value"].add(match.group(1)) + for use_item in re.finditer( + rf"(?ms)^[ \t]*{visibility}use\s+(.+?);", text + ): + for alias in re.finditer(r"\bas\s+([A-Za-z_]\w*)", use_item.group(1)): + declarations["type"].add(alias.group(1)) + return declarations + + def enum_variants(text: str, enum_match: re.Match[str]) -> list[tuple[str, int]]: opening = text.find("{", enum_match.end()) if opening < 0: @@ -233,29 +348,40 @@ def scan_tree( root: Path, ) -> tuple[dict[ApiKey, ApiItem], dict[tuple[str, str], set[str]]]: masks: dict[str, str] = {} + declarations: dict[str, dict[str, set[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")) + declarations[relative] = local_declarations(masks[relative]) uses: dict[tuple[str, str], set[str]] = {} for relative, text in masks.items(): + local = declarations[relative] + local_names = set().union(*local.values()) 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) + name = match.group(1) + if name not in local_names: + uses.setdefault(("fn", name), set()).add(relative) for name in set(re.findall(r"\.\s*([A-Za-z_]\w*)\b", text)): - uses.setdefault(("field", name), set()).add(relative) + if name not in local_names: + 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) + if name not in local_names: + 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) + if name not in local_names: + 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) + if name not in local_names: + 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) + if owner not in local_names: + uses.setdefault((owner, member), set()).add(relative) items: dict[ApiKey, ApiItem] = {} function_pattern = re.compile( diff --git a/tools/test_public_api_consumer_gate.py b/tools/test_public_api_consumer_gate.py index 2b71efea..d1fda70a 100644 --- a/tools/test_public_api_consumer_gate.py +++ b/tools/test_public_api_consumer_gate.py @@ -155,6 +155,100 @@ fn test_reference() { misaligned_core::test_only(); } raise AssertionError(f"stale allowlist entry unexpectedly passed:\n{stale.stdout}") print(" ok rejects: stale public API allowlist entry") + with tempfile.TemporaryDirectory(prefix="misaligned-api-collision-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", "// baseline\n") + write( + repo / "crates/frontend/src/main.rs", + """mod other { pub struct Widget; pub const VALUE: u8 = 1; } +use other::{Widget as AliasCollision, VALUE as ALIAS_VALUE}; +fn local_collision() {} +fn generic() {} +fn arrow U, U>() {} +enum LocalEnum { Bare } +struct Tuple(u8); +struct Unit; +struct LocalCollision { local_field: u8 } +const LOCAL_VALUE: u8 = 1; +fn main() { + local_collision(); + generic::(); + arrow:: u8, u8>(); + let _ = LocalEnum::Bare; + let _ = core::mem::size_of::(); + let _ = ALIAS_VALUE; + let local = LocalCollision { local_field: LOCAL_VALUE }; + let _ = local.local_field; + let _ = misaligned_core::Foreign { accidental: 1 }; +} +""", + ) + 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", + ) + write( + repo / "crates/misaligned-core/src/lib.rs", + """pub fn local_collision() {} +pub struct LocalCollision { + pub local_field: u8, +} +pub const LOCAL_VALUE: u8 = 1; +pub struct T; +pub struct U; +pub struct Bare; +pub struct AliasCollision; +pub const ALIAS_VALUE: u8 = 1; +pub struct Foreign { + pub accidental: u8, +} +""", + ) + collision = run( + repo, + "python3", + "tools/public_api_consumer_gate.py", + "--base", + "HEAD", + check=False, + ) + if collision.returncode == 0: + raise AssertionError( + f"unrelated same-named local symbols masked new orphans:\n{collision.stdout}" + ) + for name in ( + "local_collision", + "LocalCollision", + "local_field", + "LOCAL_VALUE", + "T", + "U", + "Bare", + "AliasCollision", + "ALIAS_VALUE", + ): + if name not in collision.stdout: + raise AssertionError( + f"missing collision diagnostic for {name}:\n{collision.stdout}" + ) + if "Foreign" in collision.stdout or "accidental" in collision.stdout: + raise AssertionError( + f"tuple/unit declarations swallowed an exact later consumer:\n{collision.stdout}" + ) + print(" ok rejects: same-named local declarations and uses do not consume core APIs") + print("public API consumer gate fixtures: OK") return 0 diff --git a/wiki/log/2026-08-04-public-api-symbol-collisions.md b/wiki/log/2026-08-04-public-api-symbol-collisions.md new file mode 100644 index 00000000..cba9e096 --- /dev/null +++ b/wiki/log/2026-08-04-public-api-symbol-collisions.md @@ -0,0 +1,41 @@ +# 2026-08-04 — Public API symbol-collision defense + +``` +Type: log +``` + +## Finding + +The new closed-workspace consumer gate grouped lexical uses only by declaration +kind and symbol name. A production file could therefore define and use an +unrelated helper, type/generic/import-alias binding, constant or enum variant, +or named field with the same name as a new `misaligned-core` export; that local +code made the orphan appear consumed and the gate passed. Scratch repositories +reproduced these false consumers in green runs. + +## Change + +- The scan now records each production file's own function, type, generic + parameter, import alias, const/static, enum-variant, macro, and struct/union + field declarations. +- Uses in a file with a same-kind local declaration are treated as ambiguous + rather than attributed to the core export. +- Added a focused committed-baseline fixture whose frontend defines and uses + four colliding local symbols before same-named core exports appear. + +## Verification + +- `python3 tools/test_public_api_consumer_gate.py` +- `python3 tools/public_api_consumer_gate.py` +- `python3 -m py_compile tools/public_api_consumer_gate.py tools/test_public_api_consumer_gate.py` +- `./tools/check.sh --docs` + +## Defense + +The collision fixture introduces a same-named core function, structs, named +field, and constant after the frontend already owns and uses colliding local +symbols, including a generic parameter, bare enum variant, and renamed import. +The gate must report every ambiguous core item as a new orphan while preserving +an exact consumer beyond tuple/unit declarations. Existing fixtures still prove +that an unambiguous cross-file production consumer passes, so the repair removes +false demand without turning every lexical use into a failure. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index ea899fe2..9174dfc6 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -11,6 +11,11 @@ add or amend a session log, then re-run the generator. +## 2026-08-04 - Public API symbol-collision defense + +- Intent: (see session log) +- Log: [wiki/log/2026-08-04-public-api-symbol-collisions.md](2026-08-04-public-api-symbol-collisions.md) + ## 2026-08-04 - Remove the public API orphans rustc cannot see - Intent: (see session log) diff --git a/wiki/log/decisions/2026-08-04.md b/wiki/log/decisions/2026-08-04.md index d5ab5dfc..cca47466 100644 --- a/wiki/log/decisions/2026-08-04.md +++ b/wiki/log/decisions/2026-08-04.md @@ -17,8 +17,11 @@ Type: log 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. + is shaped by declaration kind. A different file's own same-named function, + type/generic/import-alias binding, value/enum variant, or named field and the + local uses of that binding are ambiguous rather than consumer evidence. 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. @@ -30,6 +33,10 @@ Type: log 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. +- **Treat a same-named local declaration/import alias and its calls/accesses as + core demand.** A lexical scanner cannot assign those uses to the core export, + so accepting them would let an unrelated helper preserve new compatibility + debt. - **Scan only newly added declarations.** Removing the last consumer must be a regression too. diff --git a/wiki/process/agent-scale.md b/wiki/process/agent-scale.md index 5b2f819e..e2fe534a 100644 --- a/wiki/process/agent-scale.md +++ b/wiki/process/agent-scale.md @@ -614,8 +614,12 @@ 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. +test-only item, comment, or string is not. A source file that declares or +imports under an alias its own same-named function, type or generic binding, +value or enum variant, or named field is also ambiguous rather than consumer +evidence: its local binding and uses cannot keep a core export alive +accidentally. 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 @@ -640,6 +644,10 @@ and stale entries fail closed. 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. +5. A production file's private helper, type/generic/import-alias binding, + value/enum variant, or named field plus its ordinary uses cannot satisfy a + same-named core function, type, value, or field. Ambiguous lexical evidence + fails closed rather than preserving the export by coincidence. ## Relationship to the crate workspace