From 09e95a0b6d459f95a20ff4ceeebc406e9d8e52c2 Mon Sep 17 00:00:00 2001 From: randypanding Date: Sat, 22 Aug 2026 04:10:07 +0800 Subject: [PATCH 1/3] =?UTF-8?q?SW-03=20Pass=20=E5=A5=91=E7=BA=A6=E6=96=87?= =?UTF-8?q?=E6=A1=88=E8=B5=84=E4=BA=A7=E5=8C=96=EF=BC=9Ap3/p5=20=E5=86=85?= =?UTF-8?q?=E5=B5=8C=E5=A5=91=E7=BA=A6=E5=AD=97=E7=AC=A6=E4=B8=B2=E6=90=AC?= =?UTF-8?q?=E5=85=A5=20spec/passes/contracts.yaml?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 spec/passes/contracts.yaml(ADR-0015,资产层 A5):p3 三条输出格式契约 + p5 必现视觉/命名/字数目标模板(${name} 占位,string.Template 填充) - nsc.passes.contract_text() 统一读取注入;p3 常量与 p5 构造函数改为消费 spec - 文案逐字节不变(对拍 origin/main 的字面量,见 PR 描述验证) - prompts/** 不动(GEPA 生成物);契约变更经 spec_sha 使缓存失效 - 测试:tests/test_pass_contracts.py(3 例,先红后绿) --- adr/0015-pass-contract-strings-as-asset.md | 47 +++++++++++++++ spec/passes/contracts.yaml | 32 +++++++++++ src/nsc/passes/__init__.py | 18 ++++++ src/nsc/passes/p3_beatsheet.py | 28 ++------- src/nsc/passes/p5_dialogue.py | 44 ++++++++------ tests/test_pass_contracts.py | 67 ++++++++++++++++++++++ 6 files changed, 197 insertions(+), 39 deletions(-) create mode 100644 adr/0015-pass-contract-strings-as-asset.md create mode 100644 spec/passes/contracts.yaml create mode 100644 tests/test_pass_contracts.py diff --git a/adr/0015-pass-contract-strings-as-asset.md b/adr/0015-pass-contract-strings-as-asset.md new file mode 100644 index 0000000..cd83724 --- /dev/null +++ b/adr/0015-pass-contract-strings-as-asset.md @@ -0,0 +1,47 @@ +# ADR-0015:Pass 契约文案作为资产(spec/passes/contracts.yaml) + +- 状态:proposed +- 日期:2026-08-22 +- 影响层:A5 知识(+ A6 配置) + +## 背景 + +p3_beatsheet 与 p5_dialogue 把"机械复述给模型的输出格式契约"(`_SP_CONTRACT`、 +`_FACT_CONTRACT`、`_SC_CONTRACT`、必现视觉/命名/字数目标文案)以 Python 字符串字面量 +硬编码在 `src/nsc/passes/`。这与 AGENTS.md §2 "禁止在 prompt/代码里硬编码自然语言 +知识"相悖:这些文案是规范知识,不是机制;改一句契约要动代码层,也无法在资产层审阅。 + +它们也不能进 `prompts/.json`:prompts/** 是 GEPA 的生成物(B1),禁止手改, +只有 `nsc optimize` / `nsc compile-prompts` 能写。契约文案需要人工精确维护, +不是优化对象。 + +## 决定 + +把 Pass 的静态契约文案搬进 `spec/passes/contracts.yaml`(资产层),Pass 在组装 +LLM 输入时经 `nsc.passes.contract_text(pass_name, key)` 读取注入;带动态数据的 +文案(品牌名、字数目标等)保留代码侧机械派生,模板用 `${name}` 占位 +(string.Template),模板本体仍在 yaml。 + +## 被否决的替代 + +| 替代 | 为什么否决 | +|---|---| +| 写进 prompts/.json | prompts/** 禁止手改(B1 生成物),契约需要人工精确维护 | +| 写进 signature docstring | docstring 是"种子指令",会被 GEPA 优化漂移,契约必须稳定 | +| 保留在代码里加注释 | 违反 AGENTS.md §2 反模式;文案漂移无资产层审阅 | + +## 对下游的约束 + +- 新增/修改 Pass 输出格式契约 → 改 `spec/passes/contracts.yaml`,不再进 `.py`。 +- 契约文案变更会改变 spec/passes 域指纹 → 经 spec_sha 使该缓存失效(预期行为)。 +- 模板占位符语法固定为 `${name}`(string.Template),避免与 JSON 示例里的花括号冲突。 + +## 迁移 + +纯搬家,文案逐字节不变(见 `tests/test_pass_contracts.py` 与 PR 描述的字节一致性 +验证)。无 schema/IR 变更,无回滚成本:revert 即回代码字面量。 + +## 验证 + +`tests/test_pass_contracts.py`:键完整性、p3 常量与 spec 同源、p5 模板填充结果与 +原 f-string 输出逐字节一致;全量 `pytest -m "not llm"` 绿。 diff --git a/spec/passes/contracts.yaml b/spec/passes/contracts.yaml new file mode 100644 index 0000000..253b397 --- /dev/null +++ b/spec/passes/contracts.yaml @@ -0,0 +1,32 @@ +# SW-03 / ADR-0015:Pass 输出契约文案(资产层 A5 知识)。 +# +# 这些是"机械复述给模型的格式契约":描述输出 JSON 的形状与填法,属于规范知识, +# 按 AGENTS.md §2 不得硬编码在 src/ 代码里;它们也不是 GEPA 优化对象 +# (优化对象只有 prompts/.json 的 instructions,prompts/** 禁止手改)。 +# 运行时占位符用 ${name}(string.Template),由 Pass 用品牌/预算数据填充。 +schema_version: "1.0" + +p3_beatsheet: + # setup_payoffs_json 的格式契约(防把下标写成描述) + setup_payoffs: |- + setup_payoffs_json 每个条目形如 {"slug":"小写短标识","setup":,"payoff": 或 "PENDING:<对方条目 slug>","kind":"prop|line|promise|secret|skill","description":"一句话"}。setup/payoff 只能填整数下标(0 起)或 PENDING 字符串,绝不能填情节描述文字。 + # facts_json 的格式契约(ADR-0012;同集引用用下标,跨集引用用 known_facts 里的 id) + facts: |- + facts_json 每个条目形如 {"content":"一句话事实","type":"character_detail|relationship|backstory|plot_event|foreshadowing|world_rule","status":"active|unresolved|resolved|deprecated","resolves":null 或同集 facts_json 下标 int 或 known_facts 里 fact 的 id 字符串,"episode_no":int,"narrative_weight":"low|medium|high"}。尚未回收的伏笔 resolves 填 null;被回收后的状态翻转由系统统一完成,无需自己改前集状态。 + # state_changes_json 的格式契约(ADR-0012;key 必须来自 declared_state) + state_changes: |- + state_changes_json 每个条目形如 {"key":"declared_state 里已声明的状态变量/暗线 key","delta":number 型变量用数值/string 型用字符串/暗线推进用 int 步数,"reason":"一句话原因"}。 + +p5_dialogue: + # 必现视觉/必提台词契约的静态基底(动态示范行见 brand_must_example) + brand_must_base: |- + must_include_lines 里的每一句必须在某条对白(dialogue)中逐字原文出现;must_include_visuals 里的每一项必须逐字原文写进某条 line_type=action 的动作行,不得改写、不得替换其中任何词(例如不得把'logo'换成'标志')。 + # 有必现视觉时追加的示范动作行(${visual} = 第一项视觉符号) + brand_must_example: |- + 示范动作行:"镜头拉近,${visual}清晰可见。"——动作行里必须出现与该视觉项完全一致的字面子串。 + # 产品命名契约模板(BM-009 真相在 brand 资产;列表由代码机械派生) + product_naming: |- + 产品名唯一规范写法:${canonical}。任何语境(对白、动作行、菜单、招牌、字幕)都不得单独使用简称或变体(如 ${forbidden}),提到产品必须写完整规范名。 + # 本场对白字数目标模板(DLG-006 前置指导;数值由代码按 Beat 时长×语速推算) + dialogue_length_target: |- + 本场对白(dialogue)总字数目标 ${chars_lo}-${chars_hi} 字(按本场 Beat 时长 ${secs}s × ${cps} 字/秒推算);对白太少会导致成片时长不足(DLG-006)。 diff --git a/src/nsc/passes/__init__.py b/src/nsc/passes/__init__.py index 3c6736e..b0cadda 100644 --- a/src/nsc/passes/__init__.py +++ b/src/nsc/passes/__init__.py @@ -8,10 +8,12 @@ import json from dataclasses import dataclass, field +from functools import lru_cache from pathlib import Path from typing import Any import dspy +import yaml from ulid import ULID from nsc.runtime.cache import cached_pass @@ -21,12 +23,28 @@ "PassContext", "PassFailure", "cached_pass", + "contract_text", "generate_json", "new_id", "optional_json", "with_diag", ] +_CONTRACTS_PATH = Path("spec/passes/contracts.yaml") + + +@lru_cache(maxsize=1) +def _contracts() -> dict[str, Any]: + """SW-03 / ADR-0015:Pass 契约文案真相在 spec/passes/contracts.yaml(资产层)。""" + if not _CONTRACTS_PATH.exists(): + return {} + return yaml.safe_load(_CONTRACTS_PATH.read_text("utf-8")) or {} + + +def contract_text(pass_name: str, key: str) -> str: + """读一个 Pass 的契约文案;含 ${name} 占位(string.Template),由调用方填充。""" + return str(_contracts().get(pass_name, {}).get(key) or "") + def with_diag(inputs: dict[str, Any], fragment: dict[str, Any]) -> dict[str, Any]: """把重试诊断(_previous_failure)从 fragment 转发进 LLM 输入(D13 反馈驱动再生成)。""" diff --git a/src/nsc/passes/p3_beatsheet.py b/src/nsc/passes/p3_beatsheet.py index ffa2a6d..c488f55 100644 --- a/src/nsc/passes/p3_beatsheet.py +++ b/src/nsc/passes/p3_beatsheet.py @@ -26,6 +26,7 @@ PassContext, PassFailure, cached_pass, + contract_text, inner_json, new_id, optional_json, @@ -38,29 +39,10 @@ Beat, skip=("id", "kind", "parent_id", "order", "provenance_id", "locked", "brand_moment_id") ) -#: setup_payoffs_json 的格式契约(本模块输出契约,机械复述给模型,防把下标写成描述)。 -_SP_CONTRACT = ( - 'setup_payoffs_json 每个条目形如 {"slug":"小写短标识","setup":,' - '"payoff": 或 "PENDING:<对方条目 slug>","kind":"prop|line|promise|secret|skill",' - '"description":"一句话"}。setup/payoff 只能填整数下标(0 起)或 PENDING 字符串,' - "绝不能填情节描述文字。" -) - -#: facts_json 的格式契约(ADR-0012;同集引用用下标,跨集引用用 known_facts 里的 id)。 -_FACT_CONTRACT = ( - 'facts_json 每个条目形如 {"content":"一句话事实",' - '"type":"character_detail|relationship|backstory|plot_event|foreshadowing|world_rule",' - '"status":"active|unresolved|resolved|deprecated",' - '"resolves":null 或同集 facts_json 下标 int 或 known_facts 里 fact 的 id 字符串,' - '"episode_no":int,"narrative_weight":"low|medium|high"}。' - "尚未回收的伏笔 resolves 填 null;被回收后的状态翻转由系统统一完成,无需自己改前集状态。" -) - -#: state_changes_json 的格式契约(ADR-0012;key 必须来自 declared_state)。 -_SC_CONTRACT = ( - 'state_changes_json 每个条目形如 {"key":"declared_state 里已声明的状态变量/暗线 key",' - '"delta":number 型变量用数值/string 型用字符串/暗线推进用 int 步数,"reason":"一句话原因"}。' -) +#: 三条输出格式契约的文案真相在 spec/passes/contracts.yaml(SW-03 / ADR-0015)。 +_SP_CONTRACT = contract_text("p3_beatsheet", "setup_payoffs") +_FACT_CONTRACT = contract_text("p3_beatsheet", "facts") +_SC_CONTRACT = contract_text("p3_beatsheet", "state_changes") class Module(DSPyPass): diff --git a/src/nsc/passes/p5_dialogue.py b/src/nsc/passes/p5_dialogue.py index b8bb2a1..b504a37 100644 --- a/src/nsc/passes/p5_dialogue.py +++ b/src/nsc/passes/p5_dialogue.py @@ -5,6 +5,7 @@ import json from dataclasses import asdict from pathlib import Path +from string import Template from typing import Any, cast from nsc.revise.gate import Counts, decide @@ -12,7 +13,16 @@ from spec.ir.nodes import Line from spec.passes import signatures -from . import DSPyPass, PassContext, PassFailure, cached_pass, inner_json, new_id, with_diag +from . import ( + DSPyPass, + PassContext, + PassFailure, + cached_pass, + contract_text, + inner_json, + new_id, + with_diag, +) from .schema_bridge import allowed_values, schema_hint #: Line 字段真相在 spec/ir;beat_index 是归属下标(Pass 装配用),id 等由 Pass 分配。 @@ -25,15 +35,16 @@ class Module(DSPyPass): pass_name = "p5_dialogue" +#: 契约文案真相在 spec/passes/contracts.yaml(SW-03 / ADR-0015);动态部分用 Template 填充。 + + def _visual_contract(visuals: list[Any]) -> str: """必现视觉契约文案:逐字原文要求 + 用品牌数据动态生成的示范动作行。""" - base = ( - "must_include_lines 里的每一句必须在某条对白(dialogue)中逐字原文出现;" - "must_include_visuals 里的每一项必须逐字原文写进某条 line_type=action 的动作行," - "不得改写、不得替换其中任何词(例如不得把'logo'换成'标志')。" - ) + base = contract_text("p5_dialogue", "brand_must_base") if visuals: - base += f'示范动作行:"镜头拉近,{visuals[0]}清晰可见。"——动作行里必须出现与该视觉项完全一致的字面子串。' + base += Template(contract_text("p5_dialogue", "brand_must_example")).substitute( + visual=visuals[0] + ) return base @@ -50,10 +61,15 @@ def _naming_contract(brand: dict[str, Any]) -> str: ] if not canonical: return "" - return ( - f"产品名唯一规范写法:{canonical}。任何语境(对白、动作行、菜单、招牌、字幕)" - f"都不得单独使用简称或变体(如 {sorted(set(forbidden)) or '别名'})," - "提到产品必须写完整规范名。" + return Template(contract_text("p5_dialogue", "product_naming")).substitute( + canonical=canonical, forbidden=sorted(set(forbidden)) or "别名" + ) + + +def _dialogue_length_target(chars_lo: int, chars_hi: int, scene_secs: float, cps: float) -> str: + """本场对白字数目标文案(DLG-006 的前置指导;数值按 Beat 时长 × 语速推算)。""" + return Template(contract_text("p5_dialogue", "dialogue_length_target")).substitute( + chars_lo=chars_lo, chars_hi=chars_hi, secs=f"{scene_secs:.0f}", cps=cps ) @@ -90,11 +106,7 @@ def run(ctx: PassContext, fragment: dict[str, Any]) -> dict[str, Any]: "must_include_visuals": json.dumps(visuals, ensure_ascii=False), "brand_must_contract": _visual_contract(visuals), "product_naming_contract": _naming_contract(ctx.brand), - "dialogue_length_target": ( - f"本场对白(dialogue)总字数目标 {chars_lo}-{chars_hi} 字" - f"(按本场 Beat 时长 {scene_secs:.0f}s × {cps} 字/秒推算);" - "对白太少会导致成片时长不足(DLG-006)。" - ), + "dialogue_length_target": _dialogue_length_target(chars_lo, chars_hi, scene_secs, cps), "profile_json": json.dumps(ctx.profile, ensure_ascii=False), "retrieved_cases": fragment.get("retrieved_cases", ""), "line_schema_hint": _LINE_HINT + ";另需 beat_index: int(归属第几个 Beat,从 0)", diff --git a/tests/test_pass_contracts.py b/tests/test_pass_contracts.py new file mode 100644 index 0000000..66f5065 --- /dev/null +++ b/tests/test_pass_contracts.py @@ -0,0 +1,67 @@ +"""SW-03 Pass 契约文案资产化:p3/p5 内嵌的机械契约字符串真相搬到 spec/passes/contracts.yaml。 + +规则依据(AGENTS.md §2):禁止在 prompt/代码里硬编码自然语言知识; +prompts/** 是 GEPA 生成物禁止手改,所以契约文案进 spec/ 资产层、编译时注入。 +""" + +from __future__ import annotations + +from pathlib import Path + +import yaml + +_SPECS = { + "p3_beatsheet": { + "setup_payoffs": ["PENDING:", "下标", "slug"], + "facts": ["resolves", "known_facts", "narrative_weight"], + "state_changes": ["declared_state", "delta"], + }, + "p5_dialogue": { + "brand_must_base": ["逐字原文", "action"], + "brand_must_example": ["${visual}", "示范动作行"], + "product_naming": ["${canonical}", "${forbidden}"], + "dialogue_length_target": ["${chars_lo}", "${chars_hi}", "DLG-006"], + }, +} + + +def test_contracts_yaml_exists_with_all_keys(): + data = yaml.safe_load(Path("spec/passes/contracts.yaml").read_text("utf-8")) + for pass_name, keys in _SPECS.items(): + section = data.get(pass_name, {}) + for key, needles in keys.items(): + assert section.get(key), f"{pass_name}.{key} 缺失" + for needle in needles: + assert needle in section[key], f"{pass_name}.{key} 缺少关键片段 {needle!r}" + + +def test_p3_constants_sourced_from_spec(): + """p3 模块常量必须来自 spec 资产(代码里不得再各存一份漂移副本)。""" + from nsc.passes import contract_text + from nsc.passes import p3_beatsheet as p3 + + assert contract_text("p3_beatsheet", "setup_payoffs") == p3._SP_CONTRACT + assert contract_text("p3_beatsheet", "facts") == p3._FACT_CONTRACT + assert contract_text("p3_beatsheet", "state_changes") == p3._SC_CONTRACT + + +def test_p5_contract_builders_use_spec_templates(): + from nsc.passes import p5_dialogue as p5 + + base = p5._visual_contract([]) + assert "逐字原文" in base and "示范动作行" not in base + with_visual = p5._visual_contract(["特写镜头"]) + assert '示范动作行:"镜头拉近,特写镜头清晰可见。"' in with_visual + + naming = p5._naming_contract( + { + "products": [ + {"name": "元气茶", "canonical_name": "元气满满乌龙茶", "aliases": ["元气茶"]} + ] + } + ) + assert "元气满满乌龙茶" in naming and "元气茶" in naming + assert p5._naming_contract({"products": []}) == "" + + target = p5._dialogue_length_target(100, 200, 50, 4.5) + assert "100-200 字" in target and "50s × 4.5" in target and "DLG-006" in target From f446ff67251038251fbea4833db54a8deceffe82 Mon Sep 17 00:00:00 2001 From: randypanding Date: Sat, 22 Aug 2026 06:15:50 +0800 Subject: [PATCH 2/3] =?UTF-8?q?SW-03=20review=20=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=EF=BC=9A=E5=A5=91=E7=BA=A6=E8=B5=84=E4=BA=A7=E8=AF=BB=E5=8F=96?= =?UTF-8?q?=E5=8E=BB=E8=BF=9B=E7=A8=8B=E7=BC=93=E5=AD=98=20+=20=E7=BC=BA?= =?UTF-8?q?=E5=A4=B1=20fail=20fast?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review(PR #10): - _contracts() 的 lru_cache 会让同进程内的 spec 编辑读到陈旧契约 → 改为每次重读 (文件小、调用频率低) - 文件不可读/键缺失原返回空串(静默丢约束)→ 转 PassFailure 诊断句 - 回归:test_missing_asset_fails_fast / test_contract_text_rereads_asset --- src/nsc/passes/__init__.py | 30 ++++++++++++++++++++++-------- tests/test_pass_contracts.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 8 deletions(-) diff --git a/src/nsc/passes/__init__.py b/src/nsc/passes/__init__.py index b0cadda..6c9736d 100644 --- a/src/nsc/passes/__init__.py +++ b/src/nsc/passes/__init__.py @@ -8,7 +8,6 @@ import json from dataclasses import dataclass, field -from functools import lru_cache from pathlib import Path from typing import Any @@ -33,17 +32,32 @@ _CONTRACTS_PATH = Path("spec/passes/contracts.yaml") -@lru_cache(maxsize=1) def _contracts() -> dict[str, Any]: - """SW-03 / ADR-0015:Pass 契约文案真相在 spec/passes/contracts.yaml(资产层)。""" - if not _CONTRACTS_PATH.exists(): - return {} - return yaml.safe_load(_CONTRACTS_PATH.read_text("utf-8")) or {} + """SW-03 / ADR-0015:Pass 契约文案真相在 spec/passes/contracts.yaml(资产层)。 + + 每次调用重读(文件小、调用频率低):进程内缓存会让同进程的 spec 编辑 + 读到陈旧契约(review 修正)。 + """ + try: + return yaml.safe_load(_CONTRACTS_PATH.read_text("utf-8")) or {} + except OSError as e: + raise PassFailure(None, f"契约资产不可读:{_CONTRACTS_PATH}({e})") from e def contract_text(pass_name: str, key: str) -> str: - """读一个 Pass 的契约文案;含 ${name} 占位(string.Template),由调用方填充。""" - return str(_contracts().get(pass_name, {}).get(key) or "") + """读一个 Pass 的契约文案;含 ${name} 占位(string.Template),由调用方填充。 + + 文件或键缺失即 PassFailure(fail fast,review 修正):契约缺失意味着资产 + 打包/键名损坏,静默降级为空串会把格式约束整个丢给模型。 + """ + section = _contracts().get(pass_name) + if section is None or key not in section: + raise PassFailure( + None, + f"spec/passes/contracts.yaml 缺少 {pass_name}.{key};契约资产不完整," + "请检查文件是否被截断或键名拼写。", + ) + return str(section[key]) def with_diag(inputs: dict[str, Any], fragment: dict[str, Any]) -> dict[str, Any]: diff --git a/tests/test_pass_contracts.py b/tests/test_pass_contracts.py index 66f5065..d9f5699 100644 --- a/tests/test_pass_contracts.py +++ b/tests/test_pass_contracts.py @@ -8,6 +8,7 @@ from pathlib import Path +import pytest import yaml _SPECS = { @@ -45,6 +46,34 @@ def test_p3_constants_sourced_from_spec(): assert contract_text("p3_beatsheet", "state_changes") == p3._SC_CONTRACT +def test_missing_asset_fails_fast(monkeypatch, tmp_path): + """review 修正:契约资产缺失/键缺失必须 PassFailure,不得静默降级为空串。""" + from nsc.passes import PassFailure, contract_text + + monkeypatch.setattr("nsc.passes._CONTRACTS_PATH", tmp_path / "nonexistent.yaml") + with pytest.raises(PassFailure, match="不可读"): + contract_text("p3_beatsheet", "setup_payoffs") + + good = Path("spec/passes/contracts.yaml") + monkeypatch.setattr("nsc.passes._CONTRACTS_PATH", good) + with pytest.raises(PassFailure, match="p9_nothing.missing_key"): + contract_text("p9_nothing", "missing_key") + + +def test_contract_text_rereads_asset(monkeypatch, tmp_path): + """review 修正:同进程内的 spec 编辑必须立刻可见(不做进程级缓存)。""" + import yaml as y + + from nsc.passes import contract_text + + f = tmp_path / "contracts.yaml" + f.write_text(y.safe_dump({"p3_beatsheet": {"setup_payoffs": "v1"}}), "utf-8") + monkeypatch.setattr("nsc.passes._CONTRACTS_PATH", f) + assert contract_text("p3_beatsheet", "setup_payoffs") == "v1" + f.write_text(y.safe_dump({"p3_beatsheet": {"setup_payoffs": "v2"}}), "utf-8") + assert contract_text("p3_beatsheet", "setup_payoffs") == "v2", "进程内不得缓存旧契约" + + def test_p5_contract_builders_use_spec_templates(): from nsc.passes import p5_dialogue as p5 From 047e2fa83d553db7516e151c57aa640d3220ec05 Mon Sep 17 00:00:00 2001 From: randypanding Date: Sat, 22 Aug 2026 06:24:49 +0800 Subject: [PATCH 3/3] =?UTF-8?q?SW-03=20review=20=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=E8=A1=A5=E4=B8=81=EF=BC=9Apytest.raises=20match=20=E7=94=A8=20?= =?UTF-8?q?raw=20string=EF=BC=88RUF043=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_pass_contracts.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_pass_contracts.py b/tests/test_pass_contracts.py index d9f5699..c4d2a55 100644 --- a/tests/test_pass_contracts.py +++ b/tests/test_pass_contracts.py @@ -56,7 +56,7 @@ def test_missing_asset_fails_fast(monkeypatch, tmp_path): good = Path("spec/passes/contracts.yaml") monkeypatch.setattr("nsc.passes._CONTRACTS_PATH", good) - with pytest.raises(PassFailure, match="p9_nothing.missing_key"): + with pytest.raises(PassFailure, match=r"p9_nothing\.missing_key"): contract_text("p9_nothing", "missing_key")