diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c343614d..fa7b3777 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -778,3 +778,55 @@ jobs: ) echo "Deleted ${deleted} intermediate Linux artifact(s); retained ${retained} failed-retrace fixture(s)." + + pipe-gates: + name: MGPipe generators and hygiene gates + runs-on: ubuntu-latest + # Deliberately independent of build-linux: these are source-level gates, they take + # seconds, and a broken build must not hide a drifted interface. + + steps: + - name: Checkout repo + uses: actions/checkout@v6 + + # The seven generators all read MG_Pipe/*.def, so regenerating and diffing is what + # keeps the two interface tables, the wire records, the verify comparators, the + # PipeInputs field ids, the read-inventory coverage and the render-state member list + # from drifting apart from the catalogue. The generated files are committed + # deliberately: the build must not depend on python. + - name: Regenerate the MGPipe interface (G1-G7) + run: | + python3 scripts/gen_pipe.py + git diff --exit-code -- MobileGL/MG_Pipe/generated + + # Per-draw fprintf/printf instrumentation has repeatedly been committed by accident, + # once inside a mutex critical section. Nothing under these two trees prints to a + # stdio stream today - MGLOG_D compiles out in INFO builds and is the only channel + # they are allowed to use - so this gate starts with no exceptions, and any addition + # to it needs a reason in the pull request rather than a quiet whitelist entry. + - name: No stdio instrumentation in MG_Backend or MG_State + run: | + if grep -rnE 'fprintf[[:space:]]*\(stderr|(^|[^[:alnum:]_>.])printf[[:space:]]*\(' \ + MobileGL/MG_Backend MobileGL/MG_State; then + echo "::error::stdio instrumentation found; use MGLOG_D (compiled out in INFO builds)" + exit 1 + fi + echo "no fprintf(stderr / printf( under MobileGL/MG_Backend or MobileGL/MG_State" + + # Informational: the frontend mutation surface an MGPipe aggregate generation has to + # cover. It becomes a gate in P1, when the mapping file exists to diff against. + - name: MGPipe dirty-surface report + run: python3 scripts/gen_pipe_dirty_surface.py --summary + + # Warning only for now: the disaggregation documents are still being written, and a + # lint that fails a rewrite in progress teaches people to ignore it. It becomes + # --strict when the documents settle. + - name: Documentation citation lint + run: | + shopt -s nullglob + documents=(docs/Disaggregated/*.md) + if [ ${#documents[@]} -eq 0 ]; then + echo "no disaggregation documents to check" + exit 0 + fi + python3 scripts/check_doc_citations.py "${documents[@]}" || true diff --git a/scripts/check_doc_citations.py b/scripts/check_doc_citations.py new file mode 100644 index 00000000..d53659b4 --- /dev/null +++ b/scripts/check_doc_citations.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +# MobileGL - scripts/check_doc_citations.py +# Copyright (c) 2025-2026 MobileGL-Dev +# Licensed under the GNU Lesser General Public License v3.0: +# https://www.gnu.org/licenses/gpl-3.0.txt +# https://www.gnu.org/licenses/lgpl-3.0.txt +# SPDX-License-Identifier: LGPL-3.0-only +# End of Source File Header +"""Resolve every `path:line` citation in a set of markdown documents. + +A design document that cites the code is only as good as its line numbers, and a wrong one +is worse than none: it sends the next reader to a function that does something else. The +disaggregation plan's first draft cited SamplerObject.h:468-492 for a struct that lives at +:72-96 in a 160-line file, and nothing caught it. + +So every `File.h:123` and `File.cpp:123-456` in the given documents is resolved against a +git revision - the file must exist there and must have at least that many lines. Bare file +names are resolved by basename, which is how the plan spells most of its citations; an +ambiguous basename is reported rather than guessed. + + python3 scripts/check_doc_citations.py docs/Disaggregated/*.md + python3 scripts/check_doc_citations.py --rev 81b17c0b --strict docs/Disaggregated/*.md + +Exits non-zero only with --strict, so it can be wired into CI as a warning first. +""" + +import argparse +import os +import re +import subprocess +import sys + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +# `Managers.cpp:4340-4390`, `MobileGL/MG_State/.../RenderState.h:263`, `:12-13` is NOT +# matched on purpose: a citation with no file name cannot be checked, only guessed. +CITATION_RE = re.compile( + r"(? 1 and "/" in cited: + candidates = [p for p in candidates if p.endswith(cited)] or candidates + return candidates + + def LineCount(self, path): + if path not in self.LineCounts: + blob = git(["show", "%s:%s" % (self.Rev, path)]) + # A file with no trailing newline still has that last line. + count = blob.count("\n") + (1 if blob and not blob.endswith("\n") else 0) + self.LineCounts[path] = count + return self.LineCounts[path] + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("documents", nargs="+", help="markdown files to check") + parser.add_argument("--rev", default="HEAD", help="git revision the citations point into") + parser.add_argument("--strict", action="store_true", help="exit 1 when a citation does not resolve") + args = parser.parse_args() + + tree = Tree(args.rev) + checked = 0 + problems = [] + for document in args.documents: + if not os.path.exists(document): + problems.append("%s: no such document" % document) + continue + with open(document, "r", encoding="utf-8", errors="replace") as handle: + lines = handle.read().splitlines() + for number, line in enumerate(lines, start=1): + for match in CITATION_RE.finditer(line): + cited, first, last = match.group(1), int(match.group(2)), match.group(3) + last = int(last) if last else first + checked += 1 + where = "%s:%d: `%s`" % (document, number, match.group(0)) + candidates = tree.Resolve(cited) + if not candidates: + problems.append("%s -> no such file at %s" % (where, args.rev)) + continue + if len(candidates) > 1: + problems.append("%s -> ambiguous: %s" % (where, ", ".join(sorted(candidates)))) + continue + if last < first: + problems.append("%s -> inverted line range" % where) + continue + count = tree.LineCount(candidates[0]) + if last > count: + problems.append("%s -> %s has %d lines at %s" + % (where, candidates[0], count, args.rev)) + + print("check_doc_citations: %d citations in %d document(s) against %s, %d problem(s)" + % (checked, len(args.documents), args.rev, len(problems))) + for problem in problems: + print(" %s" % problem) + if problems and args.strict: + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/gen_pipe_dirty_surface.py b/scripts/gen_pipe_dirty_surface.py new file mode 100644 index 00000000..36deee1c --- /dev/null +++ b/scripts/gen_pipe_dirty_surface.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python3 +# MobileGL - scripts/gen_pipe_dirty_surface.py +# Copyright (c) 2025-2026 MobileGL-Dev +# Licensed under the GNU Lesser General Public License v3.0: +# https://www.gnu.org/licenses/gpl-3.0.txt +# https://www.gnu.org/licenses/lgpl-3.0.txt +# SPDX-License-Identifier: LGPL-3.0-only +# End of Source File Header +"""The dirty-surface scanner (plan B corollary 4, section 5.2). + +MGPipe replaces "the backend rediscovers what changed" with "the frontend says what +changed", which only works if EVERY frontend mutation that a backend can observe bumps an +aggregate generation. The failure mode is silent and one-directional: a mutation that +forgets to bump renders stale, and no purity gate can see it. + +So the mutation surface has to be enumerated mechanically rather than by memory. This +script reports every place in MG_Impl/GLImpl where a GL entry point BOTH mutates frontend +state through pGLContext AND reaches the backend in the same function - those are the +publish points, the ones that must map onto an aggregate generation. + +P0 is the skeleton: it reports. P1 adds the mapping file and CI regenerates it with +`git diff --exit-code` and zero unmapped mutators, the same shape as gen_pipe.py's G6. + + python3 scripts/gen_pipe_dirty_surface.py # human-readable report + python3 scripts/gen_pipe_dirty_surface.py --summary # counts only +""" + +import argparse +import os +import re +import sys + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +SCAN_ROOT = os.path.join(REPO_ROOT, "MobileGL", "MG_Impl", "GLImpl") + +# The mutating half of GLContext's surface. Prefix-matched, per the plan's list. +MUTATOR_PREFIXES = ("Add", "Set", "Mark", "Bump", "Allocate", "Truncate", "Record", "Notify", + "Begin", "End") + +MUTATOR_RE = re.compile(r"pGLContext->\s*((?:%s)\w*)\s*\(" % "|".join(MUTATOR_PREFIXES)) +BACKEND_RE = re.compile(r"gBackendFunctionsTable\.GL\.(\w+)|pActiveBackendObject->\s*(\w+)") +FUNCTION_RE = re.compile(r"(?:^|\n)[ \t]*(?:[A-Za-z_][\w:<>,&*\s]*?)\b(\w+)\s*\([^;{}]*\)\s*" + r"(?:const\s*)?(?:noexcept\s*)?\{") + + +def mask_comments_and_strings(text): + """Replace comment and string-literal bodies with spaces, keeping every offset and + newline, so the regexes below cannot match inside a comment or a literal.""" + out = list(text) + i = 0 + n = len(text) + while i < n: + c = text[i] + if c == "/" and i + 1 < n and text[i + 1] == "/": + while i < n and text[i] != "\n": + out[i] = " " + i += 1 + elif c == "/" and i + 1 < n and text[i + 1] == "*": + out[i] = out[i + 1] = " " + i += 2 + while i < n and not (text[i] == "*" and i + 1 < n and text[i + 1] == "/"): + if text[i] != "\n": + out[i] = " " + i += 1 + if i < n: + out[i] = " " + if i + 1 < n: + out[i + 1] = " " + i += 2 + elif c in "\"'": + quote = c + i += 1 + while i < n and text[i] != quote: + if text[i] == "\\": + out[i] = " " + i += 1 + if i < n and text[i] != "\n": + out[i] = " " + i += 1 + if i < n: + out[i] = " " + i += 1 + else: + i += 1 + return "".join(out) + + +def function_bodies(masked): + """Yield (name, start_offset, end_offset) for every braced function body.""" + for match in FUNCTION_RE.finditer(masked): + name = match.group(1) + start = masked.index("{", match.end() - 1) if masked[match.end() - 1] != "{" else match.end() - 1 + depth = 0 + i = start + while i < len(masked): + if masked[i] == "{": + depth += 1 + elif masked[i] == "}": + depth -= 1 + if depth == 0: + yield name, start, i + break + i += 1 + + +def line_of(text, offset): + return text.count("\n", 0, offset) + 1 + + +def scan_file(path): + with open(path, "r", encoding="utf-8", errors="replace") as handle: + text = handle.read() + masked = mask_comments_and_strings(text) + findings = [] + # Every mutator in the file, whether or not it shares a function with a backend call. + # The difference between this and the publish points below is the whole point of the + # report: a mutation that does NOT reach the backend in the same function is published + # by the NEXT verb, and it is exactly those that need an aggregate generation rather + # than an inline push. + all_mutators = [(m.group(1), line_of(masked, m.start())) for m in MUTATOR_RE.finditer(masked)] + for name, start, end in function_bodies(masked): + body = masked[start:end] + mutators = [(m.group(1), line_of(masked, start + m.start())) for m in MUTATOR_RE.finditer(body)] + if not mutators: + continue + backend = sorted(set(m.group(1) or m.group(2) for m in BACKEND_RE.finditer(body))) + if not backend: + continue + findings.append({ + "function": name, + "line": line_of(masked, start), + "mutators": mutators, + "backend": backend, + }) + return findings, all_mutators + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--summary", action="store_true", help="print the counts only") + args = parser.parse_args() + + if not os.path.isdir(SCAN_ROOT): + sys.exit("missing %s" % SCAN_ROOT) + + sources = [] + for root, _, files in os.walk(SCAN_ROOT): + for name in sorted(files): + if name.endswith((".cpp", ".h")): + sources.append(os.path.join(root, name)) + sources.sort() + + total_functions = 0 + total_mutators = 0 + deferred_mutators = 0 + distinct_mutators = {} + distinct_all = {} + for path in sources: + findings, all_mutators = scan_file(path) + for mutator, _ in all_mutators: + distinct_all[mutator] = distinct_all.get(mutator, 0) + 1 + deferred_mutators += len(all_mutators) + if not findings: + continue + relative = os.path.relpath(path, REPO_ROOT).replace(os.sep, "/") + if not args.summary: + print("\n%s" % relative) + for finding in findings: + total_functions += 1 + total_mutators += len(finding["mutators"]) + for mutator, _ in finding["mutators"]: + distinct_mutators[mutator] = distinct_mutators.get(mutator, 0) + 1 + if args.summary: + continue + print(" %s (line %d) -> backend: %s" % (finding["function"], finding["line"], + ", ".join(finding["backend"][:4]))) + for mutator, line in finding["mutators"]: + print(" %-44s :%d UNMAPPED" % (mutator, line)) + + print("\ndirty-surface: %d files scanned under MG_Impl/GLImpl" % len(sources)) + print("dirty-surface: %d mutator calls in total, %d distinct mutators" % (deferred_mutators, + len(distinct_all))) + print("dirty-surface: %d of them sit in %d IMMEDIATE PUBLISH POINTS - functions that also " + "reach the backend - across %d distinct mutators" + % (total_mutators, total_functions, len(distinct_mutators))) + print("dirty-surface: the remaining %d are DEFERRED: nothing reaches the backend in the same " + "function, so the next verb publishes them, and each one needs an aggregate generation" + % (deferred_mutators - total_mutators)) + print("dirty-surface: distinct mutators, by call count") + for mutator in sorted(distinct_all, key=lambda k: (-distinct_all[k], k)): + print(" %5d %s%s" % (distinct_all[mutator], mutator, + " (immediate)" if mutator in distinct_mutators else "")) + print("dirty-surface: every mutator above is UNMAPPED - the aggregate-generation mapping file " + "lands in P1, and this report is what it has to cover.") + print("dirty-surface: known limits of this scanner - it matches braced function bodies " + "textually, so a mutator inside a lambda is attributed to the enclosing function, and a " + "mutation published through a helper the entry point calls reads as deferred here.") + return 0 + + +if __name__ == "__main__": + sys.exit(main())