From 40e3388dc37cb1f0bcb048466f70254193b63e1b Mon Sep 17 00:00:00 2001 From: TermiNexus Date: Sun, 1 Nov 2026 19:03:56 +0800 Subject: [PATCH] feat(gen): per-file strict CWA mode via `from __future__ import __cwa__` A file opts into the strict NAFTAG lifetime mode with a leading from __future__ import __cwa__ and only that file changes: every other module keeps the legacy @life escape analysis, so existing code is untouched. - detect_strict_cwa publishes the flag per file (reset before each translation, also visible to the .vpi pass); the directive must be the file's first statement, a module docstring may precede it - the directive is not a module import: import translation returns early and the .vpi writer skips it, so importers never look for `__future__` - strict files reject @life/@willFree with LifetimeError; the OBS call-site contracts are the only boundary syntax there - Translator.StrictCWA carries the mode for the current file --- App/lib/Phase/BuildPipeline.vp | 4 ++ App/lib/Stub/StubGen/Converter.vp | 3 ++ App/lib/core/Gen/GenApi.vp | 80 +++++++++++++++++++++++++++++ App/lib/core/Gen/ImportsGenMixin.vp | 5 ++ App/lib/core/Gen/Translator.vp | 9 ++++ 5 files changed, 101 insertions(+) diff --git a/App/lib/Phase/BuildPipeline.vp b/App/lib/Phase/BuildPipeline.vp index ec41553..d809386 100644 --- a/App/lib/Phase/BuildPipeline.vp +++ b/App/lib/Phase/BuildPipeline.vp @@ -330,6 +330,9 @@ def TranslateFileGetTrans(mb: memhub.MemBuddy*, file_path: str, sha1_val: str, c err_col: int = ast.get_parse_err_col() VLogger.error(f"parse failed: {file_path} (line {err_line} col {err_col})", "PARSE") return None + # Per-file strict NAFTAG opt-in (`from __future__ import __cwa__`): detect it before the + # vpi pass so stub generation and translation agree on the mode. + GenApi.set_strict_cwa(GenApi.detect_strict_cwa(tree)) # Generate the .vpi stub from the AST before tr.translate (same as Phase1); it lets # other modules query function signatures and TypedefT definitions. if temp_dir is not None: @@ -360,6 +363,7 @@ def TranslateFileGetTrans(mb: memhub.MemBuddy*, file_path: str, sha1_val: str, c # App pre-registration (Phase A-pre) switch: when 1, import handling + dependency vpi # injection also run in the declare phase. includes pre-registration must pass 0. tr._pre_allow_dep_inject = pre_allow_dep_inject + tr.StrictCWA = GenApi.strict_cwa() # P2: reuse the global gen singleton (created by App/main.vp); create it as a fallback # when unregistered (defensive for direct-call paths). gen: GenGenerator.Generator* = GenGenerator._g_gen diff --git a/App/lib/Stub/StubGen/Converter.vp b/App/lib/Stub/StubGen/Converter.vp index 86c05b3..8087129 100644 --- a/App/lib/Stub/StubGen/Converter.vp +++ b/App/lib/Stub/StubGen/Converter.vp @@ -1411,6 +1411,9 @@ def _GeneratePyiFromAst(mb: memhub.MemBuddy*, tree: ast.AST*, rel_path: str, buf # ImportFrom statement elif k == ast.ASTKind.ImportFrom: ifr: ast.ImportFrom* = ast.ImportFrom*(stmt) + # The strict-CWA opt-in is a per-file directive, not a dependency: writing it + # into the .vpi would make importers look for a `__future__` module. + if ifr is not None and ifr.module is not None and string.strcmp(ifr.module, "__future__") == 0: continue pos = _PyiEmit(buf, buf_size, pos, "from ") lvl: int = ifr.level while lvl > 0: diff --git a/App/lib/core/Gen/GenApi.vp b/App/lib/core/Gen/GenApi.vp index 71fdb0e..a66000e 100644 --- a/App/lib/core/Gen/GenApi.vp +++ b/App/lib/core/Gen/GenApi.vp @@ -114,6 +114,82 @@ def get_current_file() -> str: return None +# ---------------- strict NAFTAG mode, per file (`from __future__ import __cwa__`) ---------------- +# The opt-in is per file, so the flag is reset for every translation (BuildPipeline calls +# detect_strict_cwa before the vpi pass). Legacy @life annotations are rejected when set. +_strict_cwa: int = 0 + + +def set_strict_cwa(flag: int) -> None: + global _strict_cwa + _strict_cwa = flag + + +def strict_cwa() -> int: + return _strict_cwa + + +# _future_directive_names_ok - 1 when the from-import names are exactly `__cwa__`. +def _future_directive_names_ok(impf: ast.ImportFrom*) -> int: + if impf is None or impf.names is None or len(impf.names) != 1: return 0 + al: ast.Alias* = ast.Alias*(impf.names.get(0)) + if al is None or al.name is None or al.asname is not None: return 0 + if string.strcmp(al.name, "__cwa__") == 0: return 1 + return 0 + + +# detect_strict_cwa - detect the file-level opt-in and publish it to strict_cwa(). +# The directive must be the first statement (a module docstring may precede it); +# a `from __future__ import ...` anywhere else fails fast. +def detect_strict_cwa(tree: ast.AST*) -> int: + global _strict_cwa + _strict_cwa = 0 + if tree is None or tree.children is None: return 0 + ch: list[ast.AST*]* = tree.children + first: int = -1 + for ci in range(int(len(ch))): + child: ast.AST* = ch.get(ci) + if child is None: continue + kd: int = child.kind() + # The module docstring is not executable code: it may precede the directive. + if first < 0 and kd == ast.ASTKind.Expr: + ex: ast.Expr* = ast.Expr*(child) + if ex is not None and ex.value is not None and ex.value.kind() == ast.ASTKind.Constant: + cst: ast.Constant* = ast.Constant*(ex.value) + if cst is not None and cst.const_kind == ast.CONST_STR: continue + if first < 0: first = ci + if kd != ast.ASTKind.ImportFrom: continue + impf: ast.ImportFrom* = ast.ImportFrom*(child) + if impf is None or impf.module is None: continue + if string.strcmp(impf.module, "__future__") != 0: continue + if first != ci: + fatal_error(child, "`from __future__ import ...` must be the first statement of the file", "SyntaxError") + continue + if _future_directive_names_ok(impf) == 0: + fatal_error(child, "unsupported `__future__` import: only `from __future__ import __cwa__` is known", "SyntaxError") + continue + _strict_cwa = 1 + return _strict_cwa + + +# handle_future_import - 1 when node is the per-file directive, which is not a module import. +def handle_future_import(trans: HT.Translator*, node: ast.AST*) -> int: + if node is None or node.kind() != ast.ASTKind.ImportFrom: return 0 + impf: ast.ImportFrom* = ast.ImportFrom*(node) + if impf is None or impf.module is None: return 0 + if string.strcmp(impf.module, "__future__") != 0: return 0 + # Not the first statement (detect_strict_cwa would have accepted it) or a nested copy. + if trans is not None and trans.StrictCWA == 0: + fatal_error(node, "`from __future__ import ...` must be the first statement of the file", "SyntaxError") + return 1 + + +# _reject_legacy_life - fail fast when a strict file still carries an @life contract. +def _reject_legacy_life(node: ast.AST*) -> None: + if strict_cwa() == 0: return + fatal_error(node, "@life/@willFree is not available in a strict CWA file (`from __future__ import __cwa__`); write the boundary contract at the call site instead", "LifetimeError") + + # ---------------- _is_ctypedef_annotation (delegates to TypeGenCType._is_ctypedef_annotation) ---------------- def _is_ctypedef_annotation(annotation: ast.AST*) -> int: """Delegate to gen._is_ctypedef_annotation""" @@ -432,12 +508,14 @@ def compute_willfree_mask(fd: ast.AST*) -> int: fname: ast.Name* = ast.Name*(dc.func) if fname.id is None: continue if string.strcmp(fname.id, "life") == 0: + _reject_legacy_life(deco) # @life(, , ...):逐个标签并入 if dc.args is not None: an: sizet = len(dc.args) for j in range(int(an)): mask = _WillFreeMergeTag(fn, dc.args.get(j), mask) elif string.strcmp(fname.id, "willFree") == 0: + _reject_legacy_life(deco) # 兼容形式:@willFree(0) 直接叠加(与 @life 等价、可混用) mask = _WillFreeMergeTag(fn, deco, mask) return mask @@ -523,6 +601,7 @@ def compute_escape_directives(fd: ast.AST*, out: sizet*) -> int: if dc.func is None or dc.func.kind() != ast.ASTKind.Name: continue fname: ast.Name* = ast.Name*(dc.func) if fname.id is None or string.strcmp(fname.id, "life") != 0: continue + _reject_legacy_life(deco) if dc.args is None: continue an: sizet = len(dc.args) j: sizet = 0 @@ -594,6 +673,7 @@ def compute_life_anno(fd: ast.AST*, out: sizet*) -> int: if dc.func is None or dc.func.kind() != ast.ASTKind.Name: continue fname: ast.Name* = ast.Name*(dc.func) if fname.id is None or string.strcmp(fname.id, "life") != 0: continue + _reject_legacy_life(deco) if dc.args is None: continue an: sizet = len(dc.args) j: sizet = 0 diff --git a/App/lib/core/Gen/ImportsGenMixin.vp b/App/lib/core/Gen/ImportsGenMixin.vp index 5f9e035..817ed09 100644 --- a/App/lib/core/Gen/ImportsGenMixin.vp +++ b/App/lib/core/Gen/ImportsGenMixin.vp @@ -11,6 +11,7 @@ import sys import io import lib.Stub.StubMerger as StubMerger import lib.core.CompilerFactory as CompilerFactory +import lib.core.Gen.GenApi as GenApi import lib.core.Gen.Translator as HT import lib.core.builtin.ViperBuild as ViperBuild import lib.Stub.StubMerger.StubMergerStore as StubMergerStore @@ -672,6 +673,8 @@ novirtual class ImportsGenMixin: """Translate the module part of a from-import statement.""" impf: ast.ImportFrom* = ast.ImportFrom*(node) if impf is None: return 0 + # `from __future__ import __cwa__` is a per-file mode switch, not a module import. + if GenApi.handle_future_import(trans, node) != 0: return 0 # Resolve the module name: relative imports need the package prefix resolved: str = self._resolve_relative_module(trans.Pool, trans.CurrentPackage, impf.level, impf.module) if resolved is not None: @@ -721,6 +724,8 @@ novirtual class ImportsGenMixin: """Translate the name part of a from-import statement.""" impf: ast.ImportFrom* = ast.ImportFrom*(node) if impf is None: return 0 + # Per-file directive only; nothing is imported into the module namespace. + if GenApi.handle_future_import(trans, node) != 0: return 0 # Resolve the module name: relative imports need the package prefix resolved: str = self._resolve_relative_module(trans.Pool, trans.CurrentPackage, impf.level, impf.module) if resolved is not None: diff --git a/App/lib/core/Gen/Translator.vp b/App/lib/core/Gen/Translator.vp index 7cc8d7b..687b375 100644 --- a/App/lib/core/Gen/Translator.vp +++ b/App/lib/core/Gen/Translator.vp @@ -232,6 +232,11 @@ novirtual class Translator: # Phase 1a declare mode: 1=register struct/enum/union only (no body), 0=full translation _declare_only: int + # Per-file strict NAFTAG mode: 1 when the file opted in with + # `from __future__ import __cwa__`. The legacy @life boundary syntax is then + # rejected and only the OBS call-site contracts are accepted. + StrictCWA: int + # Expression-statement context: 1 = the surrounding statement discards the call result # (e.g. a bare `VLogger.error(...)` line). A placeholder return type is then harmless, # so cross-module return-type resolution must not fail-fast in this context. @@ -291,6 +296,7 @@ novirtual class Translator: self.GenericTypeParamNames = None self.GenericTypeArgs = None self._declare_only = 0 + self.StrictCWA = 0 self._pre_allow_dep_inject = 0 self.SymTab = None # REPL mode context: unset by default (= non-REPL, all Gen-side interception bypassed) @@ -517,6 +523,9 @@ novirtual class Translator: # Module-level translation (user main defined) -> delegate to HandlesMain def _translate_module_level(self, pool: memhub.MemBuddy*, mod: mlirlite.LLVMModule*, tree: ast.AST*): """Delegate to GenApi.translate_children() to translate module-level statements (trans arg).""" + # Per-file strict NAFTAG opt-in: must be known before imports/classes/functions are + # translated, so every GenApi check in this file sees the same mode. + self.StrictCWA = GenApi.detect_strict_cwa(tree) # File-level class-name pre-registration must run in all three modes: it is the only source of # evidence #5 for `_norm_type_arg_name` rule 5.5 and is hoisted above the _declare_only gate. GenApi.register_file_classes(pool, self.ModuleSha1, tree)