🐛 fix(scripts): force LF for generated text files

This commit is contained in:
csh 2026-05-30 07:59:56 +08:00
parent c1bce2fb72
commit 7db5e4a697
3 changed files with 110 additions and 20 deletions

View File

@ -285,7 +285,7 @@ def load_progress_lines(progress_path: Path) -> list[str]:
def write_progress_lines(progress_path: Path, lines: list[str]) -> None: 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: def get_thread_lock(lock_path: Path) -> threading.Lock:

View File

@ -405,7 +405,7 @@ def write_docs_index(dest_prefix: Path, langs: list[str]) -> None:
lines = build_docs_index_lines(langs) lines = build_docs_index_lines(langs)
docs_index = dest_prefix / "docs/index.md" docs_index = dest_prefix / "docs/index.md"
ensure_dir(docs_index.parent) 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: 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}`", f"- `{docs_index_path}`",
"- `.agents/index.md`", "- `.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: 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)}", f"- Langs: {','.join(langs)}",
"- Generated-by: scripts/playbook.py", "- 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: 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") text = file_path.read_text(encoding="utf-8")
updated = replace_placeholders(text, project_name, date_value, playbook_scripts) updated = replace_placeholders(text, project_name, date_value, playbook_scripts)
if updated != text: 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( def resolve_template_target(
@ -648,7 +652,7 @@ def update_agents_section(
return return
if not agents_path.exists(): 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") log("Created: AGENTS.md")
return return
@ -669,14 +673,16 @@ def update_agents_section(
in_block = False in_block = False
continue continue
updated.append(line) 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)") log("Updated: AGENTS.md (section)")
else: else:
if ".agents/index.md" in agents_text: if ".agents/index.md" in agents_text:
log("Skip: AGENTS.md already references .agents/index.md") log("Skip: AGENTS.md already references .agents/index.md")
return return
updated = agents_text.rstrip("\n") + "\n\n" + "\n".join(block) + "\n" 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)") log("Appended: AGENTS.md (section)")
@ -789,7 +795,9 @@ def sync_claude_md(project_root: Path, config: dict) -> None:
if not claude_md.exists(): if not claude_md.exists():
ensure_dir(claude_md.parent) 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.") log(f"Created {claude_md.relative_to(project_root)} with playbook block.")
return return
@ -811,13 +819,15 @@ def sync_claude_md(project_root: Path, config: dict) -> None:
in_block = False in_block = False
continue continue
updated.append(line) 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).") log("Updated CLAUDE.md (playbook block).")
elif "@AGENTS.md" in text: elif "@AGENTS.md" in text:
log("Skip: CLAUDE.md already references AGENTS.md") log("Skip: CLAUDE.md already references AGENTS.md")
else: else:
appended = text.rstrip("\n") + "\n\n" + "\n".join(block_lines) + "\n" 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") 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) backup_path(rules_dst, no_backup)
text = rules_src.read_text(encoding="utf-8") text = rules_src.read_text(encoding="utf-8")
text = replace_placeholders(text, project_name, date_value, playbook_scripts) 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") log("Synced: AGENT_RULES.md")
local_rules = project_root / "AGENT_RULES.local.md" local_rules = project_root / "AGENT_RULES.local.md"
@ -870,6 +880,7 @@ def sync_rules_action(config: dict, context: dict) -> int:
"- 同一错误发生 2 次以上时的修正规则\n" "- 同一错误发生 2 次以上时的修正规则\n"
"- 团队约定的额外约束\n", "- 团队约定的额外约束\n",
encoding="utf-8", encoding="utf-8",
newline="\n",
) )
log("Created: AGENT_RULES.local.md") log("Created: AGENT_RULES.local.md")
@ -963,7 +974,7 @@ def update_agents_block(agents_md: Path, block_lines: list[str]) -> None:
end = "<!-- playbook:agents:end -->" end = "<!-- playbook:agents:end -->"
if not agents_md.exists(): if not agents_md.exists():
content = "# Agent Instructions\n\n" + "\n".join(block_lines) + "\n" 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") log("Created AGENTS.md")
return return
@ -984,14 +995,14 @@ def update_agents_block(agents_md: Path, block_lines: list[str]) -> None:
in_block = False in_block = False
continue continue
updated.append(line) 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).") log("Updated AGENTS.md (playbook block).")
else: else:
if ".agents/index.md" in text: if ".agents/index.md" in text:
log("Skip: AGENTS.md already references .agents/index.md") log("Skip: AGENTS.md already references .agents/index.md")
return return
updated = text.rstrip("\n") + "\n\n" + "\n".join(block_lines) + "\n" 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") 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/'}", 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") 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: for pattern, replacement in patterns:
updated = pattern.sub(replacement, updated) updated = pattern.sub(replacement, updated)
if updated != text: 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: def rewrite_agents_docs_links(agents_dir: Path, docs_prefix: str) -> None:
@ -1105,7 +1116,7 @@ def sync_gitattributes_append(
if content: if content:
content += "\n\n" content += "\n\n"
content += header + "\n" + "\n".join(missing) + "\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.") 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.append("")
updated.extend(block_lines) updated.extend(block_lines)
backup_path(dst, no_backup) 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: 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).") log("Synced .gitattributes from standards (block).")

View File

@ -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()