diff --git a/scripts/main_loop.py b/scripts/main_loop.py index e8719987..7aaef0c4 100644 --- a/scripts/main_loop.py +++ b/scripts/main_loop.py @@ -285,7 +285,7 @@ def load_progress_lines(progress_path: Path) -> list[str]: def write_progress_lines(progress_path: Path, lines: list[str]) -> None: - progress_path.write_text("\n".join(lines) + "\n", encoding="utf-8") + progress_path.write_text("\n".join(lines) + "\n", encoding="utf-8", newline="\n") def get_thread_lock(lock_path: Path) -> threading.Lock: diff --git a/scripts/playbook.py b/scripts/playbook.py index 500575db..e486baac 100644 --- a/scripts/playbook.py +++ b/scripts/playbook.py @@ -405,7 +405,7 @@ def write_docs_index(dest_prefix: Path, langs: list[str]) -> None: lines = build_docs_index_lines(langs) docs_index = dest_prefix / "docs/index.md" ensure_dir(docs_index.parent) - docs_index.write_text("\n".join(lines) + "\n", encoding="utf-8") + docs_index.write_text("\n".join(lines) + "\n", encoding="utf-8", newline="\n") def write_snapshot_readme(dest_prefix: Path, deploy_root: str, langs: list[str]) -> None: @@ -431,7 +431,9 @@ def write_snapshot_readme(dest_prefix: Path, deploy_root: str, langs: list[str]) f"- `{docs_index_path}`", "- `.agents/index.md`", ] - (dest_prefix / "README.md").write_text("\n".join(lines) + "\n", encoding="utf-8") + (dest_prefix / "README.md").write_text( + "\n".join(lines) + "\n", encoding="utf-8", newline="\n" + ) def write_source_file(dest_prefix: Path, langs: list[str]) -> None: @@ -446,7 +448,9 @@ def write_source_file(dest_prefix: Path, langs: list[str]) -> None: f"- Langs: {','.join(langs)}", "- Generated-by: scripts/playbook.py", ] - (dest_prefix / "SOURCE.md").write_text("\n".join(lines) + "\n", encoding="utf-8") + (dest_prefix / "SOURCE.md").write_text( + "\n".join(lines) + "\n", encoding="utf-8", newline="\n" + ) def vendor_action(config: dict, context: dict) -> int: @@ -572,7 +576,7 @@ def replace_placeholders_in_file( text = file_path.read_text(encoding="utf-8") updated = replace_placeholders(text, project_name, date_value, playbook_scripts) if updated != text: - file_path.write_text(updated, encoding="utf-8") + file_path.write_text(updated, encoding="utf-8", newline="\n") def resolve_template_target( @@ -648,7 +652,7 @@ def update_agents_section( return if not agents_path.exists(): - agents_path.write_text(template_text + "\n", encoding="utf-8") + agents_path.write_text(template_text + "\n", encoding="utf-8", newline="\n") log("Created: AGENTS.md") return @@ -669,14 +673,16 @@ def update_agents_section( in_block = False continue updated.append(line) - agents_path.write_text("\n".join(updated) + "\n", encoding="utf-8") + agents_path.write_text( + "\n".join(updated) + "\n", encoding="utf-8", newline="\n" + ) log("Updated: AGENTS.md (section)") else: if ".agents/index.md" in agents_text: log("Skip: AGENTS.md already references .agents/index.md") return updated = agents_text.rstrip("\n") + "\n\n" + "\n".join(block) + "\n" - agents_path.write_text(updated, encoding="utf-8") + agents_path.write_text(updated, encoding="utf-8", newline="\n") log("Appended: AGENTS.md (section)") @@ -789,7 +795,9 @@ def sync_claude_md(project_root: Path, config: dict) -> None: if not claude_md.exists(): ensure_dir(claude_md.parent) - claude_md.write_text("\n".join(block_lines) + "\n", encoding="utf-8") + claude_md.write_text( + "\n".join(block_lines) + "\n", encoding="utf-8", newline="\n" + ) log(f"Created {claude_md.relative_to(project_root)} with playbook block.") return @@ -811,13 +819,15 @@ def sync_claude_md(project_root: Path, config: dict) -> None: in_block = False continue updated.append(line) - claude_md.write_text("\n".join(updated) + "\n", encoding="utf-8") + claude_md.write_text( + "\n".join(updated) + "\n", encoding="utf-8", newline="\n" + ) log("Updated CLAUDE.md (playbook block).") elif "@AGENTS.md" in text: log("Skip: CLAUDE.md already references AGENTS.md") else: appended = text.rstrip("\n") + "\n\n" + "\n".join(block_lines) + "\n" - claude_md.write_text(appended, encoding="utf-8") + claude_md.write_text(appended, encoding="utf-8", newline="\n") log("Appended playbook block to CLAUDE.md") @@ -854,7 +864,7 @@ def sync_rules_action(config: dict, context: dict) -> int: backup_path(rules_dst, no_backup) text = rules_src.read_text(encoding="utf-8") text = replace_placeholders(text, project_name, date_value, playbook_scripts) - rules_dst.write_text(text.rstrip("\n") + "\n", encoding="utf-8") + rules_dst.write_text(text.rstrip("\n") + "\n", encoding="utf-8", newline="\n") log("Synced: AGENT_RULES.md") local_rules = project_root / "AGENT_RULES.local.md" @@ -870,6 +880,7 @@ def sync_rules_action(config: dict, context: dict) -> int: "- 同一错误发生 2 次以上时的修正规则\n" "- 团队约定的额外约束\n", encoding="utf-8", + newline="\n", ) log("Created: AGENT_RULES.local.md") @@ -963,7 +974,7 @@ def update_agents_block(agents_md: Path, block_lines: list[str]) -> None: end = "" if not agents_md.exists(): content = "# Agent Instructions\n\n" + "\n".join(block_lines) + "\n" - agents_md.write_text(content, encoding="utf-8") + agents_md.write_text(content, encoding="utf-8", newline="\n") log("Created AGENTS.md") return @@ -984,14 +995,14 @@ def update_agents_block(agents_md: Path, block_lines: list[str]) -> None: in_block = False continue updated.append(line) - agents_md.write_text("\n".join(updated) + "\n", encoding="utf-8") + agents_md.write_text("\n".join(updated) + "\n", encoding="utf-8", newline="\n") log("Updated AGENTS.md (playbook block).") else: if ".agents/index.md" in text: log("Skip: AGENTS.md already references .agents/index.md") return updated = text.rstrip("\n") + "\n\n" + "\n".join(block_lines) + "\n" - agents_md.write_text(updated, encoding="utf-8") + agents_md.write_text(updated, encoding="utf-8", newline="\n") log("Appended playbook block to AGENTS.md") @@ -1030,7 +1041,7 @@ def create_agents_index(agents_root: Path, langs: list[str], docs_prefix: str | "", f"- {docs_prefix or 'docs/'}", ] - agents_index.write_text("\n".join(lines) + "\n", encoding="utf-8") + agents_index.write_text("\n".join(lines) + "\n", encoding="utf-8", newline="\n") log("Synced .agents/index.md") @@ -1056,7 +1067,7 @@ def rewrite_docs_links_in_markdown(root: Path, docs_prefix: str, recursive: bool for pattern, replacement in patterns: updated = pattern.sub(replacement, updated) if updated != text: - md_path.write_text(updated, encoding="utf-8") + md_path.write_text(updated, encoding="utf-8", newline="\n") def rewrite_agents_docs_links(agents_dir: Path, docs_prefix: str) -> None: @@ -1105,7 +1116,7 @@ def sync_gitattributes_append( if content: content += "\n\n" content += header + "\n" + "\n".join(missing) + "\n" - dst.write_text(content, encoding="utf-8") + dst.write_text(content, encoding="utf-8", newline="\n") log("Appended missing .gitattributes rules from standards.") @@ -1140,9 +1151,9 @@ def sync_gitattributes_block(src: Path, dst: Path, no_backup: bool) -> None: updated.append("") updated.extend(block_lines) backup_path(dst, no_backup) - dst.write_text("\n".join(updated) + "\n", encoding="utf-8") + dst.write_text("\n".join(updated) + "\n", encoding="utf-8", newline="\n") else: - dst.write_text("\n".join(block_lines) + "\n", encoding="utf-8") + dst.write_text("\n".join(block_lines) + "\n", encoding="utf-8", newline="\n") log("Synced .gitattributes from standards (block).") diff --git a/tests/test_script_line_endings.py b/tests/test_script_line_endings.py new file mode 100644 index 00000000..949b776b --- /dev/null +++ b/tests/test_script_line_endings.py @@ -0,0 +1,79 @@ +import ast +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT_PATHS = sorted((ROOT / "scripts").glob("*.py")) + sorted( + (ROOT / ".gitea" / "ci").glob("*.py") +) + + +class ScriptLineEndingTests(unittest.TestCase): + def test_script_text_writes_pin_lf_newlines(self): + offenders: list[str] = [] + for path in SCRIPT_PATHS: + tree = ast.parse(path.read_text(encoding="utf-8")) + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + if is_write_text_without_lf_newline(node): + offenders.append(f"{path.relative_to(ROOT)}:{node.lineno}") + elif is_text_open_for_writing_without_newline(node): + offenders.append(f"{path.relative_to(ROOT)}:{node.lineno}") + + self.assertEqual(offenders, []) + + +def has_lf_newline_keyword(node: ast.Call) -> bool: + newline = next( + (keyword for keyword in node.keywords if keyword.arg == "newline"), + None, + ) + return ( + newline is not None + and isinstance(newline.value, ast.Constant) + and newline.value.value == "\n" + ) + + +def is_write_text_without_lf_newline(node: ast.Call) -> bool: + func = node.func + return ( + isinstance(func, ast.Attribute) + and func.attr == "write_text" + and not has_lf_newline_keyword(node) + ) + + +def is_text_open_for_writing_without_newline(node: ast.Call) -> bool: + func = node.func + if isinstance(func, ast.Name): + is_open = func.id == "open" + elif isinstance(func, ast.Attribute): + is_open = func.attr == "open" + else: + is_open = False + if not is_open: + return False + + mode_node = None + if len(node.args) >= 2: + mode_node = node.args[1] + for keyword in node.keywords: + if keyword.arg == "mode": + mode_node = keyword.value + break + + if mode_node is None: + return False + if not isinstance(mode_node, ast.Constant) or not isinstance(mode_node.value, str): + return False + + mode = mode_node.value + writes_text = any(flag in mode for flag in ("w", "a", "x")) and "b" not in mode + return writes_text and not has_lf_newline_keyword(node) + + +if __name__ == "__main__": + unittest.main()