From 850800afa9a1aa107f07080ae78c2203945752c2 Mon Sep 17 00:00:00 2001 From: "Aaron K. Clark" Date: Wed, 20 May 2026 17:05:04 -0500 Subject: [PATCH] localization: pin UTF-8 explicitly on every read_text/write_text call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Path.read_text/Path.write_text default to locale.getpreferredencoding() when no encoding= is passed. On most modern Linux/macOS that's UTF-8 — but on Windows it's still cp1252 in many setups (Python 3.15 will finally default to UTF-8 everywhere via PEP 686; we're not there yet). Practical impact: an operator running socrates on Windows whose planning files contain typical non-ASCII content — em-dashes from copy-paste, smart quotes, a client name like "Café Berlin", a Spanish or Korean decision text — would get UnicodeDecodeError on read OR silent mojibake on write. Either way the file gets corrupted on a round-trip through one of socrates' commands. Pinned encoding="utf-8" on all 128 read_text/write_text call sites across src/ (49 calls) and tests/ (79 calls). All 147 tests still pass; ruff + mypy clean; ruff format applied to normalize the multi-line cases. No API change. No behavior change on Linux/macOS where UTF-8 was already the locale default. Windows operators (and any non-UTF-8 locale) now get deterministic behavior. --- src/socrates120x/audit/checks.py | 16 +-- src/socrates120x/audit/companyos_checks.py | 8 +- src/socrates120x/companyos.py | 2 +- src/socrates120x/decide.py | 4 +- src/socrates120x/extract.py | 2 +- src/socrates120x/interview.py | 4 +- src/socrates120x/journal.py | 4 +- src/socrates120x/onboard.py | 16 +-- src/socrates120x/pack.py | 6 +- src/socrates120x/patterns.py | 8 +- src/socrates120x/prompting.py | 2 +- src/socrates120x/render.py | 2 +- src/socrates120x/ship.py | 6 +- src/socrates120x/status.py | 10 +- src/socrates120x/timeline.py | 6 +- tests/test_audit.py | 29 ++-- tests/test_cli_pack.py | 38 ++++-- tests/test_companyos.py | 4 +- tests/test_companyos_audit.py | 43 ++++-- tests/test_decide.py | 45 ++++--- tests/test_extract.py | 15 ++- tests/test_interview.py | 5 +- tests/test_journal.py | 6 +- tests/test_onboard.py | 5 +- tests/test_pack.py | 58 +++++--- tests/test_patterns.py | 148 ++++++++++++++------- tests/test_render.py | 19 +-- tests/test_ship.py | 40 ++++-- tests/test_status.py | 38 ++++-- tests/test_timeline.py | 47 +++++-- 30 files changed, 414 insertions(+), 222 deletions(-) diff --git a/src/socrates120x/audit/checks.py b/src/socrates120x/audit/checks.py index 05dce22..89aef00 100644 --- a/src/socrates120x/audit/checks.py +++ b/src/socrates120x/audit/checks.py @@ -136,7 +136,7 @@ def _load_ignore_list(project: Path, section: str) -> set[str]: if not config_path.is_file(): return set() try: - data = json.loads(config_path.read_text()) + data = json.loads(config_path.read_text(encoding="utf-8")) except (OSError, ValueError): return set() if not isinstance(data, dict): @@ -198,7 +198,7 @@ def run(self, project: Path) -> list[Finding]: path = project / adapter if not path.is_file(): continue - text = path.read_text(errors="replace") + text = path.read_text(errors="replace", encoding="utf-8") if "AGENTS.md" not in text: findings.append(Finding( check=self.name, @@ -229,7 +229,7 @@ def run(self, project: Path) -> list[Finding]: acc = sprint / "acceptance.md" if not acc.is_file(): continue - for line_no, line in enumerate(acc.read_text(errors="replace").splitlines(), 1): + for line_no, line in enumerate(acc.read_text(errors="replace", encoding="utf-8").splitlines(), 1): lower = line.lower() for weasel in WEASEL_WORDS: if weasel.lower() in lower: @@ -258,7 +258,7 @@ def run(self, project: Path) -> list[Finding]: state = project / "planning" / "STATE.md" if not state.is_file(): return [] # Already flagged by RequiredFilesCheck. - text = state.read_text(errors="replace") + text = state.read_text(errors="replace", encoding="utf-8") m = self._DATE.search(text) if not m: return [Finding( @@ -294,7 +294,7 @@ def run(self, project: Path) -> list[Finding]: risks = project / "planning" / "RISKS.md" if not risks.is_file(): return [] - lower = risks.read_text(errors="replace").lower() + lower = risks.read_text(errors="replace", encoding="utf-8").lower() if not any(phrase.lower() in lower for phrase in ALWAYS_ON_RISK_PHRASES): return [Finding( check=self.name, @@ -318,7 +318,7 @@ def run(self, project: Path) -> list[Finding]: domain = project / "planning" / "DOMAIN.md" if not domain.is_file(): return [] - terms = self._extract_terms(domain.read_text(errors="replace")) + terms = self._extract_terms(domain.read_text(errors="replace", encoding="utf-8")) if not terms: return [] @@ -366,7 +366,7 @@ def _concatenate_other_files(self, project: Path) -> str: for rel in candidates: path = project / rel if path.is_file(): - parts.append(path.read_text(errors="replace")) + parts.append(path.read_text(errors="replace", encoding="utf-8")) # Also include sprint files. sprints = project / "planning" / "sprints" if sprints.is_dir(): @@ -374,7 +374,7 @@ def _concatenate_other_files(self, project: Path) -> str: if not sprint.is_dir(): continue for f in sprint.glob("*.md"): - parts.append(f.read_text(errors="replace")) + parts.append(f.read_text(errors="replace", encoding="utf-8")) return "\n".join(parts) diff --git a/src/socrates120x/audit/companyos_checks.py b/src/socrates120x/audit/companyos_checks.py index b2f0980..3fd2143 100644 --- a/src/socrates120x/audit/companyos_checks.py +++ b/src/socrates120x/audit/companyos_checks.py @@ -101,7 +101,7 @@ def run(self, project: Path) -> list[Finding]: for pattern in sorted(patterns.glob("*.md")): if pattern.name == "README.md": continue - body = pattern.read_text(errors="replace") + body = pattern.read_text(errors="replace", encoding="utf-8") m = self._SOURCE_LINE.search(body) if not m: continue @@ -137,7 +137,7 @@ def run(self, project: Path) -> list[Finding]: if not proposals.is_file() or not builds.is_dir(): return [] build_names = {p.name for p in builds.iterdir() if p.is_dir()} - body = proposals.read_text(errors="replace") + body = proposals.read_text(errors="replace", encoding="utf-8") findings: list[Finding] = [] seen: set[str] = set() for m in self._SLUG.finditer(body): @@ -167,7 +167,7 @@ def _build_client_reference(build: Path) -> str | None: if answers.is_file(): import json try: - data = json.loads(answers.read_text()) + data = json.loads(answers.read_text(encoding="utf-8")) if isinstance(data, dict): v = data.get("client") if isinstance(v, str) and v.strip(): @@ -176,7 +176,7 @@ def _build_client_reference(build: Path) -> str | None: pass agents = build / "AGENTS.md" if agents.is_file(): - text = agents.read_text(errors="replace") + text = agents.read_text(errors="replace", encoding="utf-8") m = re.search(r"Client:\s*\*\*([^*\n]+)\*\*", text) if m: return m.group(1).strip() diff --git a/src/socrates120x/companyos.py b/src/socrates120x/companyos.py index 468adc3..d6a95e9 100644 --- a/src/socrates120x/companyos.py +++ b/src/socrates120x/companyos.py @@ -57,7 +57,7 @@ def scaffold_companyos(target: Path, *, overwrite: bool = False) -> list[Path]: for rel, body in files.items(): path = target / rel path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(body) + path.write_text(body, encoding="utf-8") written.append(path) return written diff --git a/src/socrates120x/decide.py b/src/socrates120x/decide.py index c246e9a..da886ee 100644 --- a/src/socrates120x/decide.py +++ b/src/socrates120x/decide.py @@ -39,9 +39,9 @@ def record_decision(project: Path, text: str) -> int: today = _dt.date.today().isoformat() bullet = f"- **{cleaned} ({today})**" - body = decisions_path.read_text() + body = decisions_path.read_text(encoding="utf-8") new_body = _insert_decision(body, bullet) - decisions_path.write_text(new_body) + decisions_path.write_text(new_body, encoding="utf-8") print(f"Appended to {decisions_path}:") print(f" {bullet}") return 0 diff --git a/src/socrates120x/extract.py b/src/socrates120x/extract.py index 4f00257..ad319fc 100644 --- a/src/socrates120x/extract.py +++ b/src/socrates120x/extract.py @@ -138,7 +138,7 @@ def run_extract( slug = _sanitize_slug(iv.answers.get("pattern_slug", "untitled")) target = target_dir / f"CANDIDATE-{slug}.md" - target.write_text(render_pattern(iv.answers, project=project)) + target.write_text(render_pattern(iv.answers, project=project), encoding="utf-8") print(f"\nPattern candidate written to: {target}") print() print("Next steps:") diff --git a/src/socrates120x/interview.py b/src/socrates120x/interview.py index 723ad11..a1cdb62 100644 --- a/src/socrates120x/interview.py +++ b/src/socrates120x/interview.py @@ -194,10 +194,10 @@ class Interview: def load(self) -> None: if self.answers_path.exists() and self.resume: - self.answers = json.loads(self.answers_path.read_text()) + self.answers = json.loads(self.answers_path.read_text(encoding="utf-8")) def save(self) -> None: - self.answers_path.write_text(json.dumps(self.answers, indent=2) + "\n") + self.answers_path.write_text(json.dumps(self.answers, indent=2) + "\n", encoding="utf-8") def run( self, diff --git a/src/socrates120x/journal.py b/src/socrates120x/journal.py index 790908d..49e3c6a 100644 --- a/src/socrates120x/journal.py +++ b/src/socrates120x/journal.py @@ -32,7 +32,7 @@ def create_or_open_entry(project: Path, *, show: bool = False, list_all: bool = entry = journal_dir / f"{today}.md" is_new = not entry.exists() if is_new: - entry.write_text(_template(today)) + entry.write_text(_template(today), encoding="utf-8") print(f"Created {entry}") cmd = editor_command() @@ -93,5 +93,5 @@ def _show_latest(journal_dir: Path) -> int: if not entries: print("(no journal entries yet)") return 0 - print(entries[0].read_text()) + print(entries[0].read_text(encoding="utf-8")) return 0 diff --git a/src/socrates120x/onboard.py b/src/socrates120x/onboard.py index 8e7bcc6..438b53d 100644 --- a/src/socrates120x/onboard.py +++ b/src/socrates120x/onboard.py @@ -35,7 +35,7 @@ def _load_answers_json(project: Path) -> dict[str, Any] | None: if not path.is_file(): return None try: - data = json.loads(path.read_text()) + data = json.loads(path.read_text(encoding="utf-8")) if isinstance(data, dict): return data except (OSError, ValueError): @@ -79,12 +79,12 @@ def _synthesize_from_markdown(project: Path) -> str: today = _dt.date.today().isoformat() name = project.name - readme = (project / "README.md").read_text(errors="replace") if (project / "README.md").is_file() else "" - agents = (project / "AGENTS.md").read_text(errors="replace") if (project / "AGENTS.md").is_file() else "" - state = (project / "planning" / "STATE.md").read_text(errors="replace") if (project / "planning" / "STATE.md").is_file() else "" - decisions = (project / "planning" / "DECISIONS.md").read_text(errors="replace") if (project / "planning" / "DECISIONS.md").is_file() else "" - risks = (project / "planning" / "RISKS.md").read_text(errors="replace") if (project / "planning" / "RISKS.md").is_file() else "" - questions = (project / "planning" / "QUESTIONS.md").read_text(errors="replace") if (project / "planning" / "QUESTIONS.md").is_file() else "" + readme = (project / "README.md").read_text(errors="replace", encoding="utf-8") if (project / "README.md").is_file() else "" + agents = (project / "AGENTS.md").read_text(errors="replace", encoding="utf-8") if (project / "AGENTS.md").is_file() else "" + state = (project / "planning" / "STATE.md").read_text(errors="replace", encoding="utf-8") if (project / "planning" / "STATE.md").is_file() else "" + decisions = (project / "planning" / "DECISIONS.md").read_text(errors="replace", encoding="utf-8") if (project / "planning" / "DECISIONS.md").is_file() else "" + risks = (project / "planning" / "RISKS.md").read_text(errors="replace", encoding="utf-8") if (project / "planning" / "RISKS.md").is_file() else "" + questions = (project / "planning" / "QUESTIONS.md").read_text(errors="replace", encoding="utf-8") if (project / "planning" / "QUESTIONS.md").is_file() else "" tagline = _extract_tagline(readme, agents) client = _extract_field(agents, "Client") or _extract_field(readme, "Client") @@ -177,7 +177,7 @@ def write_welcome(project: Path) -> Path: """Write WELCOME.md into the project root and return its path.""" body = synthesize_welcome(project) target = project / "WELCOME.md" - target.write_text(body) + target.write_text(body, encoding="utf-8") return target diff --git a/src/socrates120x/pack.py b/src/socrates120x/pack.py index b2a6b04..cfbd929 100644 --- a/src/socrates120x/pack.py +++ b/src/socrates120x/pack.py @@ -119,7 +119,7 @@ def write_pack( format=format, ) target = project / f".socrates-architect-pack.{FORMAT_EXTENSIONS[format]}" - target.write_text(body) + target.write_text(body, encoding="utf-8") return target @@ -233,7 +233,7 @@ def _kit_sections(kit: Path) -> list[_Section]: path = kit / name if not path.is_file(): continue - text = path.read_text(errors="replace").strip() + text = path.read_text(errors="replace", encoding="utf-8").strip() if not text: continue out.append(_Section( @@ -253,7 +253,7 @@ def _file_section(path: Path, *, rel_display: str, label: str) -> _Section: path=rel_display, kind="missing", ) - text = path.read_text(errors="replace").strip() + text = path.read_text(errors="replace", encoding="utf-8").strip() if not text: return _Section( label=f"{label} (`{rel_display}`)", diff --git a/src/socrates120x/patterns.py b/src/socrates120x/patterns.py index 7a35e36..37e7b2b 100644 --- a/src/socrates120x/patterns.py +++ b/src/socrates120x/patterns.py @@ -85,7 +85,7 @@ def review_patterns(companyos_root: Path, *, use_cache: bool = True) -> PatternR candidates += 1 else: promoted += 1 - body = pattern.read_text(errors="replace") + body = pattern.read_text(errors="replace", encoding="utf-8") source = _extract_source_project(body) extracted = _extract_extracted_date(body) @@ -215,7 +215,7 @@ def _slug_in_project(slug: str, project_dir: Path) -> bool: needle = slug.lower() for f in project_dir.rglob("*.md"): try: - text = f.read_text(errors="replace").lower() + text = f.read_text(errors="replace", encoding="utf-8").lower() except OSError: continue if needle in text: @@ -245,7 +245,7 @@ def _load_usage_cache(patterns_dir: Path) -> dict[str, Any] | None: if not cache_path.is_file(): return None try: - data = json.loads(cache_path.read_text()) + data = json.loads(cache_path.read_text(encoding="utf-8")) except (OSError, ValueError): return None if not isinstance(data, dict): @@ -261,7 +261,7 @@ def _save_usage_cache(patterns_dir: Path, payload: dict[str, Any]) -> None: if not patterns_dir.is_dir(): return with contextlib.suppress(OSError): - (patterns_dir / USAGE_CACHE_FILENAME).write_text(json.dumps(payload, indent=2)) + (patterns_dir / USAGE_CACHE_FILENAME).write_text(json.dumps(payload, indent=2), encoding="utf-8") def format_pattern_report(report: PatternReport, *, use_color: bool | None = None) -> str: diff --git a/src/socrates120x/prompting.py b/src/socrates120x/prompting.py index 87288b8..18eb7d6 100644 --- a/src/socrates120x/prompting.py +++ b/src/socrates120x/prompting.py @@ -160,7 +160,7 @@ def _ask_multiline_editor(output_fn: OutputFn, q: Question) -> str: try: output_fn(f" (opening {editor[0]} — save & quit to submit)") subprocess.run([*editor, str(tmp_path)], check=True) - raw = tmp_path.read_text() + raw = tmp_path.read_text(encoding="utf-8") finally: with contextlib.suppress(OSError): tmp_path.unlink() diff --git a/src/socrates120x/render.py b/src/socrates120x/render.py index a6caebc..4beee7c 100644 --- a/src/socrates120x/render.py +++ b/src/socrates120x/render.py @@ -42,7 +42,7 @@ def render_all(target: Path, answers: dict[str, Any]) -> list[Path]: for rel, body in files.items(): path = target / rel path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(body) + path.write_text(body, encoding="utf-8") written.append(path) return written diff --git a/src/socrates120x/ship.py b/src/socrates120x/ship.py index eece6fa..80c5ba1 100644 --- a/src/socrates120x/ship.py +++ b/src/socrates120x/ship.py @@ -137,7 +137,7 @@ def _extract_check(project: Path) -> ShipFinding: if not loc.is_dir(): continue for f in loc.glob("CANDIDATE-*.md"): - text = f.read_text(errors="replace") + text = f.read_text(errors="replace", encoding="utf-8") if f"`{project.name}`" in text: found = True break @@ -180,7 +180,7 @@ def _state_check(project: Path) -> ShipFinding: answers_path = project / ".socrates-answers.json" if answers_path.is_file(): try: - data = json.loads(answers_path.read_text()) + data = json.loads(answers_path.read_text(encoding="utf-8")) except (OSError, ValueError): data = None if isinstance(data, dict) and data.get("state_next"): @@ -189,7 +189,7 @@ def _state_check(project: Path) -> ShipFinding: pass # Fall back to recency check via the embedded date. import re - m = re.search(r"Last updated:\s*(\d{4}-\d{2}-\d{2})", state.read_text(errors="replace")) + m = re.search(r"Last updated:\s*(\d{4}-\d{2}-\d{2})", state.read_text(errors="replace", encoding="utf-8")) if not m: return ShipFinding( name="state", diff --git a/src/socrates120x/status.py b/src/socrates120x/status.py index 8007ecd..8906f9c 100644 --- a/src/socrates120x/status.py +++ b/src/socrates120x/status.py @@ -134,7 +134,7 @@ def _extract_tagline(project: Path) -> str: answers_path = project / ".socrates-answers.json" if answers_path.is_file(): try: - data = json.loads(answers_path.read_text()) + data = json.loads(answers_path.read_text(encoding="utf-8")) if isinstance(data, dict): t = data.get("tagline") if isinstance(t, str): @@ -143,7 +143,7 @@ def _extract_tagline(project: Path) -> str: pass agents = project / "AGENTS.md" if agents.is_file(): - m = re.search(r"\*\*[^*]+\*\*\s+—\s+(.+)", agents.read_text(errors="replace")) + m = re.search(r"\*\*[^*]+\*\*\s+—\s+(.+)", agents.read_text(errors="replace", encoding="utf-8")) if m: return m.group(1).strip() return "" @@ -171,7 +171,7 @@ def _state_age_days(project: Path) -> int | None: state = project / "planning" / "STATE.md" if not state.is_file(): return None - m = _DATE.search(state.read_text(errors="replace")) + m = _DATE.search(state.read_text(errors="replace", encoding="utf-8")) if not m: return None try: @@ -208,9 +208,9 @@ def _has_extract(project: Path) -> bool: sibling = parent.parent / "patterns" if sibling.is_dir(): for f in sibling.glob("CANDIDATE-*.md"): - if f"Source project | `{project.name}`" in f.read_text(errors="replace"): + if f"Source project | `{project.name}`" in f.read_text(errors="replace", encoding="utf-8"): return True - if f"`{project.name}`" in f.read_text(errors="replace"): + if f"`{project.name}`" in f.read_text(errors="replace", encoding="utf-8"): return True # 3) Or has an in-progress extract answers file. return (project / ".socrates-extract-answers.json").is_file() diff --git a/src/socrates120x/timeline.py b/src/socrates120x/timeline.py index 7f455ea..95ce8b1 100644 --- a/src/socrates120x/timeline.py +++ b/src/socrates120x/timeline.py @@ -91,7 +91,7 @@ def _journal_events(project: Path) -> list[TimelineEvent]: d = _dt.date.fromisoformat(entry.stem) except ValueError: continue - first_line = _first_real_line(entry.read_text(errors="replace")) + first_line = _first_real_line(entry.read_text(errors="replace", encoding="utf-8")) events.append(TimelineEvent( date=d, kind=EventKind.JOURNAL, @@ -119,7 +119,7 @@ def _sprint_events(project: Path) -> list[TimelineEvent]: req = sprint / "requirements.md" detail = "" if req.is_file(): - detail = _extract_goal(req.read_text(errors="replace")) + detail = _extract_goal(req.read_text(errors="replace", encoding="utf-8")) events.append(TimelineEvent( date=mtime, kind=EventKind.SPRINT, @@ -137,7 +137,7 @@ def _decision_events(project: Path) -> list[TimelineEvent]: if not decisions_file.is_file(): return [] events: list[TimelineEvent] = [] - for line in decisions_file.read_text(errors="replace").splitlines(): + for line in decisions_file.read_text(errors="replace", encoding="utf-8").splitlines(): stripped = line.lstrip() if not stripped.startswith("- "): continue diff --git a/tests/test_audit.py b/tests/test_audit.py index 80eebb4..520530a 100644 --- a/tests/test_audit.py +++ b/tests/test_audit.py @@ -85,8 +85,14 @@ def test_freshly_populated_project_has_no_errors(clean_project: Path) -> None: def test_audit_runs_every_check(clean_project: Path) -> None: report = run_audit(clean_project) expected = { - "required-files", "scaffold-shape", "sprint-folders", "adapter-routing", - "acceptance-weasels", "state-freshness", "always-on-risks", "terminology-used", + "required-files", + "scaffold-shape", + "sprint-folders", + "adapter-routing", + "acceptance-weasels", + "state-freshness", + "always-on-risks", + "terminology-used", } assert set(report.checks_run) == expected @@ -114,10 +120,11 @@ def test_scaffold_shape_check_emits_info_on_pruning(clean_project: Path) -> None def test_scaffold_shape_check_honours_skip_list(clean_project: Path) -> None: """`.socrates-audit.json` lets the operator silence specific paths.""" import json + (clean_project / "docs" / "API.md").unlink() (clean_project / "docs" / "PERMISSIONS.md").unlink() (clean_project / ".socrates-audit.json").write_text( - json.dumps({"scaffold_shape": {"ignore": ["docs/API.md"]}}) + json.dumps({"scaffold_shape": {"ignore": ["docs/API.md"]}}), encoding="utf-8" ) findings = ScaffoldShapeCheck().run(clean_project) messages = [f.message for f in findings] @@ -146,7 +153,9 @@ def test_sprint_folder_check_flags_missing_files(clean_project: Path) -> None: def test_adapter_check_fires_when_pointer_missing(clean_project: Path) -> None: - (clean_project / "CLAUDE.md").write_text("# This file does not mention the router.") + (clean_project / "CLAUDE.md").write_text( + "# This file does not mention the router.", encoding="utf-8" + ) findings = AdapterPointsToAgentsCheck().run(clean_project) assert any("CLAUDE.md" in f.message for f in findings) @@ -157,7 +166,8 @@ def test_weasel_words_check_fires(clean_project: Path) -> None: "# acceptance\n\n" "- The system is robust enough for production.\n" "- Tests pass as needed.\n" - "- DOMAIN.md reflects client terminology.\n" # this line is clean + "- DOMAIN.md reflects client terminology.\n", # this line is clean + encoding="utf-8", ) findings = WeaselWordsCheck().run(clean_project) assert len(findings) == 2 @@ -167,7 +177,7 @@ def test_weasel_words_check_fires(clean_project: Path) -> None: def test_state_freshness_check_fires_on_old_date(clean_project: Path) -> None: state = clean_project / "planning" / "STATE.md" old = (_dt.date.today() - _dt.timedelta(days=120)).isoformat() - state.write_text(f"# STATE\n\n_Last updated: {old}_\n") + state.write_text(f"# STATE\n\n_Last updated: {old}_\n", encoding="utf-8") findings = StateFreshnessCheck().run(clean_project) assert len(findings) == 1 assert "120 days ago" in findings[0].message @@ -181,7 +191,7 @@ def test_state_freshness_check_quiet_when_fresh(clean_project: Path) -> None: def test_always_on_risks_check_fires_when_missing(clean_project: Path) -> None: risks = clean_project / "planning" / "RISKS.md" - risks.write_text("# RISKS\n\n- Some project-specific risk only.\n") + risks.write_text("# RISKS\n\n- Some project-specific risk only.\n", encoding="utf-8") findings = AlwaysOnRisksCheck().run(clean_project) assert len(findings) == 1 assert "source of truth" in findings[0].message.lower() @@ -195,11 +205,12 @@ def test_terminology_used_check_flags_orphan_term(clean_project: Path) -> None: "# DOMAIN\n\n" "## Terminology\n\n" "- orphaned-concept — a thing nothing else mentions\n" - "- live-term — referenced elsewhere\n" + "- live-term — referenced elsewhere\n", + encoding="utf-8", ) risks = clean_project / "planning" / "RISKS.md" risks.write_text( - risks.read_text() + "\n\nNote: a live-term issue could surface here." + risks.read_text(encoding="utf-8") + "\n\nNote: a live-term issue could surface here." ) findings = TerminologyUsedCheck().run(clean_project) flagged = {f.message for f in findings} diff --git a/tests/test_cli_pack.py b/tests/test_cli_pack.py index f4808e2..74470a9 100644 --- a/tests/test_cli_pack.py +++ b/tests/test_cli_pack.py @@ -23,15 +23,31 @@ def project(tmp_path: Path) -> Path: p = tmp_path / "cli-demo" scaffold(p) - render_all(p, { - "project_name": "cli-demo", "client": "AcmeCorp", "tagline": "demo", - "business_goal": "goal", "tech_stack": "Python", - "users": ["op"], "current_process": "manual", "terminology": [], - "business_rules": [], "decisions": ["choice — reason"], "out_of_scope": [], - "risks": ["risk one"], "fragile_inputs": "", "open_questions": ["q1?"], - "sprint1_goal": "", "sprint1_acceptance": ["one"], - "sprint1_inspect": [], "state_current": "", "state_next": "", "state_blockers": [], - }) + render_all( + p, + { + "project_name": "cli-demo", + "client": "AcmeCorp", + "tagline": "demo", + "business_goal": "goal", + "tech_stack": "Python", + "users": ["op"], + "current_process": "manual", + "terminology": [], + "business_rules": [], + "decisions": ["choice — reason"], + "out_of_scope": [], + "risks": ["risk one"], + "fragile_inputs": "", + "open_questions": ["q1?"], + "sprint1_goal": "", + "sprint1_acceptance": ["one"], + "sprint1_inspect": [], + "state_current": "", + "state_next": "", + "state_blockers": [], + }, + ) return p @@ -52,7 +68,7 @@ def test_pack_format_xml_writes_xml(project: Path) -> None: assert rc == 0 target = project / ".socrates-architect-pack.xml" assert target.is_file() - content = target.read_text() + content = target.read_text(encoding="utf-8") assert content.startswith(" None: assert rc == 0 target = project / ".socrates-architect-pack.html" assert target.is_file() - content = target.read_text() + content = target.read_text(encoding="utf-8") assert content.startswith("") diff --git a/tests/test_companyos.py b/tests/test_companyos.py index dc9dd1c..6c046d3 100644 --- a/tests/test_companyos.py +++ b/tests/test_companyos.py @@ -29,7 +29,7 @@ def test_companyos_scaffold_creates_full_macro_tree(tmp_path: Path) -> None: def test_companyos_agents_md_routes_to_builds(tmp_path: Path) -> None: target = tmp_path / "120x" scaffold_companyos(target) - agents = (target / "AGENTS.md").read_text() + agents = (target / "AGENTS.md").read_text(encoding="utf-8") assert "builds/" in agents assert "patterns/" in agents assert "clients/" in agents @@ -38,7 +38,7 @@ def test_companyos_agents_md_routes_to_builds(tmp_path: Path) -> None: def test_companyos_refuses_to_overwrite_non_empty(tmp_path: Path) -> None: target = tmp_path / "existing" target.mkdir() - (target / "junk.txt").write_text("something") + (target / "junk.txt").write_text("something", encoding="utf-8") with pytest.raises(FileExistsError): scaffold_companyos(target) diff --git a/tests/test_companyos_audit.py b/tests/test_companyos_audit.py index 89a2a1f..b0e454a 100644 --- a/tests/test_companyos_audit.py +++ b/tests/test_companyos_audit.py @@ -29,15 +29,31 @@ def company(tmp_path: Path) -> Path: def _make_build(company: Path, name: str, client: str = "Acme") -> Path: project = company / "builds" / name scaffold(project) - render_all(project, { - "project_name": name, "client": client, "tagline": "demo", - "business_goal": "g", "tech_stack": "Python", - "users": [], "current_process": "", "terminology": [], - "business_rules": [], "decisions": [], "out_of_scope": [], - "risks": [], "fragile_inputs": "", "open_questions": [], - "sprint1_goal": "", "sprint1_acceptance": [], - "sprint1_inspect": [], "state_current": "", "state_next": "", "state_blockers": [], - }) + render_all( + project, + { + "project_name": name, + "client": client, + "tagline": "demo", + "business_goal": "g", + "tech_stack": "Python", + "users": [], + "current_process": "", + "terminology": [], + "business_rules": [], + "decisions": [], + "out_of_scope": [], + "risks": [], + "fragile_inputs": "", + "open_questions": [], + "sprint1_goal": "", + "sprint1_acceptance": [], + "sprint1_inspect": [], + "state_current": "", + "state_next": "", + "state_blockers": [], + }, + ) return project @@ -85,7 +101,7 @@ def test_orphan_builds_check_quiet_when_client_folder_exists(company: Path) -> N def test_orphan_pattern_source_check_fires(company: Path) -> None: (company / "patterns" / "CANDIDATE-x.md").write_text( - "**Source project** | `ghost-project`\n" + "**Source project** | `ghost-project`\n", encoding="utf-8" ) findings = OrphanPatternSourceCheck().run(company) assert any("ghost-project" in f.message for f in findings) @@ -94,7 +110,7 @@ def test_orphan_pattern_source_check_fires(company: Path) -> None: def test_orphan_pattern_source_check_quiet_when_source_exists(company: Path) -> None: _make_build(company, "alpha") (company / "patterns" / "CANDIDATE-x.md").write_text( - "**Source project** | `alpha`\n" + "**Source project** | `alpha`\n", encoding="utf-8" ) findings = OrphanPatternSourceCheck().run(company) assert findings == [] @@ -102,8 +118,7 @@ def test_orphan_pattern_source_check_quiet_when_source_exists(company: Path) -> def test_stale_proposal_check_flags_mention(company: Path) -> None: (company / "pipeline" / "proposals.md").write_text( - "### Project — Client — 2026-01-01\n" - "Slug: `quarterly-rebates`\n" + "### Project — Client — 2026-01-01\nSlug: `quarterly-rebates`\n", encoding="utf-8" ) findings = StaleProposalCheck().run(company) assert any("quarterly-rebates" in f.message for f in findings) @@ -112,7 +127,7 @@ def test_stale_proposal_check_flags_mention(company: Path) -> None: def test_stale_proposal_check_quiet_when_build_exists(company: Path) -> None: _make_build(company, "quarterly-rebates") (company / "pipeline" / "proposals.md").write_text( - "Slug: `quarterly-rebates`\n" + "Slug: `quarterly-rebates`\n", encoding="utf-8" ) findings = StaleProposalCheck().run(company) assert findings == [] diff --git a/tests/test_decide.py b/tests/test_decide.py index 28887b8..d5ba830 100644 --- a/tests/test_decide.py +++ b/tests/test_decide.py @@ -16,31 +16,45 @@ def project(tmp_path: Path) -> Path: p = tmp_path / "demo" scaffold(p) - render_all(p, { - "project_name": "demo", "client": "Acme", "tagline": "demo", - "business_goal": "g", "tech_stack": "Python", - "users": [], "current_process": "", "terminology": [], - "business_rules": [], - "decisions": ["Initial choice X — reason"], - "out_of_scope": ["Mobile"], - "risks": [], "fragile_inputs": "", "open_questions": [], - "sprint1_goal": "", "sprint1_acceptance": [], - "sprint1_inspect": [], "state_current": "", "state_next": "", "state_blockers": [], - }) + render_all( + p, + { + "project_name": "demo", + "client": "Acme", + "tagline": "demo", + "business_goal": "g", + "tech_stack": "Python", + "users": [], + "current_process": "", + "terminology": [], + "business_rules": [], + "decisions": ["Initial choice X — reason"], + "out_of_scope": ["Mobile"], + "risks": [], + "fragile_inputs": "", + "open_questions": [], + "sprint1_goal": "", + "sprint1_acceptance": [], + "sprint1_inspect": [], + "state_current": "", + "state_next": "", + "state_blockers": [], + }, + ) return p def test_decide_appends_dated_bullet(project: Path) -> None: code = record_decision(project, "New choice Y — because Z") assert code == 0 - body = (project / "planning" / "DECISIONS.md").read_text() + body = (project / "planning" / "DECISIONS.md").read_text(encoding="utf-8") today = _dt.date.today().isoformat() assert f"New choice Y — because Z ({today})" in body def test_decide_creates_post_init_section(project: Path) -> None: record_decision(project, "First post-init choice") - body = (project / "planning" / "DECISIONS.md").read_text() + body = (project / "planning" / "DECISIONS.md").read_text(encoding="utf-8") assert "## Decisions added after init" in body # The new section must appear BEFORE the out-of-scope section. post_init_idx = body.index("## Decisions added after init") @@ -51,7 +65,7 @@ def test_decide_creates_post_init_section(project: Path) -> None: def test_decide_reuses_existing_post_init_section(project: Path) -> None: record_decision(project, "First post-init choice") record_decision(project, "Second post-init choice") - body = (project / "planning" / "DECISIONS.md").read_text() + body = (project / "planning" / "DECISIONS.md").read_text(encoding="utf-8") # There should be exactly ONE 'Decisions added after init' heading. assert body.count("## Decisions added after init") == 1 # Both bullets should be present in that section. @@ -63,7 +77,7 @@ def test_decide_reuses_existing_post_init_section(project: Path) -> None: def test_decide_preserves_sprint1_section(project: Path) -> None: record_decision(project, "Post-init choice") - body = (project / "planning" / "DECISIONS.md").read_text() + body = (project / "planning" / "DECISIONS.md").read_text(encoding="utf-8") # The original Sprint 001 section must still exist with its decision. assert "## Decisions captured during Sprint 001 discovery" in body assert "Initial choice X — reason" in body @@ -83,6 +97,7 @@ def test_decide_timeline_picks_up_new_decision(project: Path) -> None: """End-to-end: the date stamp `socrates decide` writes is the one `socrates timeline` reads.""" from socrates120x.timeline import EventKind, build_timeline + record_decision(project, "Cross-feature choice — for posterity") events = build_timeline(project) decisions = [e for e in events if e.kind is EventKind.DECISION] diff --git a/tests/test_extract.py b/tests/test_extract.py index d0360b1..9d6e8b8 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -59,10 +59,17 @@ def test_render_pattern_includes_all_sections() -> None: assert "demo" in body # project name -@pytest.mark.parametrize("missing_key", [ - "pattern_summary", "pattern_kind", "pattern_when_applies", - "pattern_when_does_not", "pattern_body", "pattern_war_story", -]) +@pytest.mark.parametrize( + "missing_key", + [ + "pattern_summary", + "pattern_kind", + "pattern_when_applies", + "pattern_when_does_not", + "pattern_body", + "pattern_war_story", + ], +) def test_render_pattern_handles_missing_field(missing_key: str) -> None: answers = { "pattern_slug": "x", diff --git a/tests/test_interview.py b/tests/test_interview.py index e7b1ad5..bac8bef 100644 --- a/tests/test_interview.py +++ b/tests/test_interview.py @@ -65,7 +65,7 @@ def test_interview_saves_incrementally(tmp_path: Path) -> None: # Answer file exists and is non-empty after the run. assert answers_path.exists() - text = answers_path.read_text() + text = answers_path.read_text(encoding="utf-8") assert "demo" in text assert text.startswith("{") @@ -94,7 +94,8 @@ def fake_editor_run(cmd: list[str], check: bool = True) -> Any: # noqa: ARG001 target.write_text( "# this line is a comment and should be stripped\n" "real answer line one\n" - "real answer line two\n" + "real answer line two\n", + encoding="utf-8", ) return subprocess.CompletedProcess(args=cmd, returncode=0) diff --git a/tests/test_journal.py b/tests/test_journal.py index 998e0a8..4bda14a 100644 --- a/tests/test_journal.py +++ b/tests/test_journal.py @@ -36,7 +36,7 @@ def no_op_run(cmd: list[str], check: bool = True) -> Any: # noqa: ARG001 entry = project / "planning" / "journal" / f"{today}.md" assert code == 0 assert entry.is_file() - body = entry.read_text() + body = entry.read_text(encoding="utf-8") assert today in body assert "What happened" in body @@ -48,7 +48,7 @@ def test_journal_list_empty_then_with_entry(project: Path, capsys: pytest.Captur assert "no journal entries yet" in out today = _dt.date.today().isoformat() - (project / "planning" / "journal" / f"{today}.md").write_text("entry") + (project / "planning" / "journal" / f"{today}.md").write_text("entry", encoding="utf-8") code = create_or_open_entry(project, list_all=True) out = capsys.readouterr().out assert today in out @@ -56,7 +56,7 @@ def test_journal_list_empty_then_with_entry(project: Path, capsys: pytest.Captur def test_journal_show_prints_latest(project: Path, capsys: pytest.CaptureFixture) -> None: today = _dt.date.today().isoformat() - (project / "planning" / "journal" / f"{today}.md").write_text("hello journal") + (project / "planning" / "journal" / f"{today}.md").write_text("hello journal", encoding="utf-8") code = create_or_open_entry(project, show=True) assert code == 0 out = capsys.readouterr().out diff --git a/tests/test_onboard.py b/tests/test_onboard.py index c61cc53..e7d37bc 100644 --- a/tests/test_onboard.py +++ b/tests/test_onboard.py @@ -99,7 +99,7 @@ def test_write_welcome_creates_file(clean_project: Path) -> None: target = write_welcome(clean_project) assert target == clean_project / "WELCOME.md" assert target.is_file() - assert "WELCOME — demo" in target.read_text() + assert "WELCOME — demo" in target.read_text(encoding="utf-8") def test_top_bullets_extracts_first_n() -> None: @@ -128,10 +128,11 @@ def test_welcome_prefers_answers_json_when_present(clean_project: Path) -> None: the WELCOME should reflect the JSON, not the markdown. """ import json + answers = _clean_answers() answers["client"] = "JsonOnlyCo" answers["decisions"] = ["JSON-only decision A — proof", "JSON-only decision B — proof"] - (clean_project / ".socrates-answers.json").write_text(json.dumps(answers)) + (clean_project / ".socrates-answers.json").write_text(json.dumps(answers), encoding="utf-8") body = synthesize_welcome(clean_project) assert "JsonOnlyCo" in body diff --git a/tests/test_pack.py b/tests/test_pack.py index 10fb074..bd1425c 100644 --- a/tests/test_pack.py +++ b/tests/test_pack.py @@ -19,15 +19,31 @@ def project(tmp_path: Path) -> Path: p = tmp_path / "demo" scaffold(p) - render_all(p, { - "project_name": "demo", "client": "AcmeCorp", "tagline": "demo tagline", - "business_goal": "the goal", "tech_stack": "Python + DuckDB", - "users": ["op"], "current_process": "manual", "terminology": [], - "business_rules": [], "decisions": ["choice — reason"], "out_of_scope": [], - "risks": ["risk one"], "fragile_inputs": "", "open_questions": ["q1?"], - "sprint1_goal": "", "sprint1_acceptance": ["criterion one"], - "sprint1_inspect": [], "state_current": "", "state_next": "", "state_blockers": [], - }) + render_all( + p, + { + "project_name": "demo", + "client": "AcmeCorp", + "tagline": "demo tagline", + "business_goal": "the goal", + "tech_stack": "Python + DuckDB", + "users": ["op"], + "current_process": "manual", + "terminology": [], + "business_rules": [], + "decisions": ["choice — reason"], + "out_of_scope": [], + "risks": ["risk one"], + "fragile_inputs": "", + "open_questions": ["q1?"], + "sprint1_goal": "", + "sprint1_acceptance": ["criterion one"], + "sprint1_inspect": [], + "state_current": "", + "state_next": "", + "state_blockers": [], + }, + ) return p @@ -63,7 +79,7 @@ def test_pack_specific_sprint(project: Path) -> None: sprint2 = project / "planning" / "sprints" / "002-rebate-engine" sprint2.mkdir() for fname in ("requirements.md", "blueprint.md", "acceptance.md", "handoff-prompt.md"): - (sprint2 / fname).write_text(f"# {fname} (sprint 2)") + (sprint2 / fname).write_text(f"# {fname} (sprint 2)", encoding="utf-8") text = build_pack(project, include_sprint="002-rebate-engine") assert "002-rebate-engine" in text # Sprint 1 files should NOT appear since we asked for sprint 2. @@ -80,7 +96,7 @@ def test_write_pack_creates_file(project: Path) -> None: target = write_pack(project) assert target == project / ".socrates-architect-pack.md" assert target.is_file() - assert "AcmeCorp" in target.read_text() + assert "AcmeCorp" in target.read_text(encoding="utf-8") def test_pack_starts_with_architect_header(project: Path) -> None: @@ -105,10 +121,10 @@ def test_pack_kit_path_embeds_kit_files(project: Path, tmp_path: Path) -> None: kit = tmp_path / "fake-kit" kit.mkdir() (kit / "120x-architect-builder-philosophy.md").write_text( - "# Fake philosophy doc body content." + "# Fake philosophy doc body content.", encoding="utf-8" ) (kit / "120x-project-scaffold-instructions.md").write_text( - "# Fake scaffold instructions content." + "# Fake scaffold instructions content.", encoding="utf-8" ) # Quickstart deliberately absent — pack should skip cleanly. text = build_pack(project, kit_path=kit) @@ -121,7 +137,7 @@ def test_pack_kit_path_embeds_kit_files(project: Path, tmp_path: Path) -> None: def test_pack_kit_path_via_env_var(project: Path, tmp_path: Path, monkeypatch) -> None: kit = tmp_path / "env-kit" kit.mkdir() - (kit / "120x-quickstart.md").write_text("# Quickstart from env\n") + (kit / "120x-quickstart.md").write_text("# Quickstart from env\n", encoding="utf-8") monkeypatch.setenv("SOCRATES_KIT_PATH", str(kit)) text = build_pack(project) assert "Quickstart from env" in text @@ -137,7 +153,9 @@ def test_pack_kit_path_missing_dir_skipped(project: Path) -> None: def test_pack_philosophy_and_kit_can_combine(project: Path, tmp_path: Path) -> None: kit = tmp_path / "kit" kit.mkdir() - (kit / "120x-architect-builder-philosophy.md").write_text("# Kit philosophy.\n") + (kit / "120x-architect-builder-philosophy.md").write_text( + "# Kit philosophy.\n", encoding="utf-8" + ) text = build_pack(project, include_philosophy=True, kit_path=kit) assert "120x Architect / Builder stance" in text # socrates preamble assert "Kit philosophy" in text # kit file @@ -197,7 +215,7 @@ def test_pack_xml_format_escapes_xml_specials(tmp_path: Path) -> None: p = tmp_path / "xml-escape-demo" scaffold(p) (p / "planning" / "DECISIONS.md").write_text( - "# Decisions\n\n- chose over Y & Z for the foo>bar case\n" + "# Decisions\n\n- chose over Y & Z for the foo>bar case\n", encoding="utf-8" ) text = build_pack(p, format="xml") # The body is inside an XML element — `<`, `>`, `&` must be escaped. @@ -211,7 +229,7 @@ def test_pack_xml_format_escapes_xml_specials(tmp_path: Path) -> None: def test_pack_html_format_emits_doctype_and_html_tags(project: Path) -> None: text = build_pack(project, format="html") assert text.startswith("") - assert "" in text + assert '' in text assert "" in text assert "" in text assert text.rstrip().endswith("") @@ -223,10 +241,10 @@ def test_pack_html_format_emits_doctype_and_html_tags(project: Path) -> None: def test_pack_html_format_includes_section_kind_attr(project: Path) -> None: text = build_pack(project, format="html") # Header section gets a
tag with data-kind="header". - assert "
. - assert "
None: diff --git a/tests/test_patterns.py b/tests/test_patterns.py index f0c219c..fdeaa9a 100644 --- a/tests/test_patterns.py +++ b/tests/test_patterns.py @@ -45,15 +45,31 @@ def company(tmp_path: Path) -> Path: def _make_build(company: Path, name: str) -> Path: project = company / "builds" / name scaffold(project) - render_all(project, { - "project_name": name, "client": "Acme", "tagline": "demo", - "business_goal": "g", "tech_stack": "Python", - "users": [], "current_process": "", "terminology": [], - "business_rules": [], "decisions": [], "out_of_scope": [], - "risks": [], "fragile_inputs": "", "open_questions": [], - "sprint1_goal": "", "sprint1_acceptance": [], - "sprint1_inspect": [], "state_current": "", "state_next": "", "state_blockers": [], - }) + render_all( + project, + { + "project_name": name, + "client": "Acme", + "tagline": "demo", + "business_goal": "g", + "tech_stack": "Python", + "users": [], + "current_process": "", + "terminology": [], + "business_rules": [], + "decisions": [], + "out_of_scope": [], + "risks": [], + "fragile_inputs": "", + "open_questions": [], + "sprint1_goal": "", + "sprint1_acceptance": [], + "sprint1_inspect": [], + "state_current": "", + "state_next": "", + "state_blockers": [], + }, + ) return project @@ -67,7 +83,7 @@ def test_patterns_review_flags_stale_candidate(company: Path) -> None: _make_build(company, "alpha") old = (_dt.date.today() - _dt.timedelta(days=120)).isoformat() (company / "patterns" / "CANDIDATE-validate-numbers.md").write_text( - _pattern(old, "alpha", "validate-numbers") + _pattern(old, "alpha", "validate-numbers"), encoding="utf-8" ) report = review_patterns(company) kinds = {f.kind for f in report.findings} @@ -76,7 +92,9 @@ def test_patterns_review_flags_stale_candidate(company: Path) -> None: def test_patterns_review_flags_orphan_source(company: Path) -> None: today = _dt.date.today().isoformat() - (company / "patterns" / "CANDIDATE-x.md").write_text(_pattern(today, "ghost-project", "x")) + (company / "patterns" / "CANDIDATE-x.md").write_text( + _pattern(today, "ghost-project", "x"), encoding="utf-8" + ) report = review_patterns(company) kinds = {f.kind for f in report.findings} assert FindingKind.ORPHAN in kinds @@ -88,11 +106,12 @@ def test_patterns_review_quiet_when_pattern_reused_elsewhere(company: Path) -> N other = _make_build(company, "beta") # Mention the pattern slug in beta's STATE. (other / "planning" / "STATE.md").write_text( - (other / "planning" / "STATE.md").read_text() + "\nReused: validate-numbers\n" + (other / "planning" / "STATE.md").read_text(encoding="utf-8") + + "\nReused: validate-numbers\n" ) today = _dt.date.today().isoformat() (company / "patterns" / "CANDIDATE-validate-numbers.md").write_text( - _pattern(today, "alpha", "validate-numbers") + _pattern(today, "alpha", "validate-numbers"), encoding="utf-8" ) report = review_patterns(company) # No UNUSED finding for this pattern (it appears in beta). @@ -106,11 +125,12 @@ def test_patterns_review_flags_unused(company: Path) -> None: _make_build(company, "beta") # exists but doesn't reference the slug # Mention slug in alpha (source) — but nowhere else. (alpha / "planning" / "STATE.md").write_text( - (alpha / "planning" / "STATE.md").read_text() + "\nMentions validate-numbers.\n" + (alpha / "planning" / "STATE.md").read_text(encoding="utf-8") + + "\nMentions validate-numbers.\n" ) today = _dt.date.today().isoformat() (company / "patterns" / "CANDIDATE-validate-numbers.md").write_text( - _pattern(today, "alpha", "validate-numbers") + _pattern(today, "alpha", "validate-numbers"), encoding="utf-8" ) report = review_patterns(company) unused = [f for f in report.findings if f.kind == FindingKind.UNUSED] @@ -125,7 +145,9 @@ def test_format_pattern_report_clean(company: Path) -> None: def test_format_pattern_report_groups_by_kind(company: Path) -> None: today = _dt.date.today().isoformat() - (company / "patterns" / "CANDIDATE-x.md").write_text(_pattern(today, "ghost", "x")) + (company / "patterns" / "CANDIDATE-x.md").write_text( + _pattern(today, "ghost", "x"), encoding="utf-8" + ) report = review_patterns(company) text = format_pattern_report(report, use_color=False) assert "orphan-source" in text @@ -139,12 +161,15 @@ def test_format_pattern_report_groups_by_kind(company: Path) -> None: def test_review_writes_usage_cache(company: Path) -> None: _make_build(company, "alpha") today = _dt.date.today().isoformat() - (company / "patterns" / "CANDIDATE-x.md").write_text(_pattern(today, "alpha", "x")) + (company / "patterns" / "CANDIDATE-x.md").write_text( + _pattern(today, "alpha", "x"), encoding="utf-8" + ) review_patterns(company) cache_path = company / "patterns" / ".usage-cache.json" assert cache_path.is_file() import json - data = json.loads(cache_path.read_text()) + + data = json.loads(cache_path.read_text(encoding="utf-8")) assert data["version"] == 2 assert "x" in data["slug_set"] assert "alpha" in data["projects"] @@ -156,19 +181,22 @@ def test_cache_per_project_segment_is_reused(company: Path) -> None: project's cached matched_slugs is trusted verbatim — including fabricated entries we inject for the test.""" import json + _make_build(company, "alpha") today = _dt.date.today().isoformat() - (company / "patterns" / "CANDIDATE-x.md").write_text(_pattern(today, "alpha", "x")) + (company / "patterns" / "CANDIDATE-x.md").write_text( + _pattern(today, "alpha", "x"), encoding="utf-8" + ) review_patterns(company) cache_path = company / "patterns" / ".usage-cache.json" - data = json.loads(cache_path.read_text()) + data = json.loads(cache_path.read_text(encoding="utf-8")) # Inject a fake matched slug into alpha's segment WITHOUT changing alpha's mtime. data["projects"]["alpha"]["matched_slugs"] = ["x", "phantom-slug"] - cache_path.write_text(json.dumps(data)) + cache_path.write_text(json.dumps(data), encoding="utf-8") review_patterns(company) - refreshed = json.loads(cache_path.read_text()) + refreshed = json.loads(cache_path.read_text(encoding="utf-8")) # The injected entry must survive — alpha's segment was reused. assert "phantom-slug" in refreshed["projects"]["alpha"]["matched_slugs"] @@ -179,18 +207,21 @@ def test_cache_invalidated_per_project_on_mtime_bump(company: Path) -> None: import json import os import time + alpha = _make_build(company, "alpha") _make_build(company, "beta") today = _dt.date.today().isoformat() - (company / "patterns" / "CANDIDATE-x.md").write_text(_pattern(today, "alpha", "x")) + (company / "patterns" / "CANDIDATE-x.md").write_text( + _pattern(today, "alpha", "x"), encoding="utf-8" + ) review_patterns(company) cache_path = company / "patterns" / ".usage-cache.json" # Inject phantom slugs into BOTH project segments. - data = json.loads(cache_path.read_text()) + data = json.loads(cache_path.read_text(encoding="utf-8")) data["projects"]["alpha"]["matched_slugs"] = ["x", "phantom-from-alpha"] data["projects"]["beta"]["matched_slugs"] = ["phantom-from-beta"] - cache_path.write_text(json.dumps(data)) + cache_path.write_text(json.dumps(data), encoding="utf-8") # Bump alpha's mtime, leave beta alone. state = alpha / "planning" / "STATE.md" @@ -199,7 +230,7 @@ def test_cache_invalidated_per_project_on_mtime_bump(company: Path) -> None: os.utime(state, (new_mtime, new_mtime)) review_patterns(company) - refreshed = json.loads(cache_path.read_text()) + refreshed = json.loads(cache_path.read_text(encoding="utf-8")) # alpha was rescanned; phantom entry is gone. assert "phantom-from-alpha" not in refreshed["projects"]["alpha"]["matched_slugs"] # beta was NOT rescanned; phantom entry survives. @@ -209,22 +240,27 @@ def test_cache_invalidated_per_project_on_mtime_bump(company: Path) -> None: def test_cache_invalidated_when_slug_set_changes(company: Path) -> None: """Adding a new pattern invalidates every project's segment.""" import json + _make_build(company, "alpha") today = _dt.date.today().isoformat() - (company / "patterns" / "CANDIDATE-x.md").write_text(_pattern(today, "alpha", "x")) + (company / "patterns" / "CANDIDATE-x.md").write_text( + _pattern(today, "alpha", "x"), encoding="utf-8" + ) review_patterns(company) cache_path = company / "patterns" / ".usage-cache.json" # Inject a phantom slug; will be wiped when the slug_set changes. - data = json.loads(cache_path.read_text()) + data = json.loads(cache_path.read_text(encoding="utf-8")) data["projects"]["alpha"]["matched_slugs"] = ["x", "phantom"] - cache_path.write_text(json.dumps(data)) + cache_path.write_text(json.dumps(data), encoding="utf-8") # Add a second pattern. slug_set changes. - (company / "patterns" / "CANDIDATE-y.md").write_text(_pattern(today, "alpha", "y")) + (company / "patterns" / "CANDIDATE-y.md").write_text( + _pattern(today, "alpha", "y"), encoding="utf-8" + ) review_patterns(company) - refreshed = json.loads(cache_path.read_text()) + refreshed = json.loads(cache_path.read_text(encoding="utf-8")) assert "phantom" not in refreshed["projects"]["alpha"]["matched_slugs"] assert set(refreshed["slug_set"]) == {"x", "y"} @@ -232,19 +268,29 @@ def test_cache_invalidated_when_slug_set_changes(company: Path) -> None: def test_use_cache_false_forces_rescan(company: Path) -> None: """use_cache=False ignores the existing cache and recomputes.""" import json + _make_build(company, "alpha") today = _dt.date.today().isoformat() - (company / "patterns" / "CANDIDATE-x.md").write_text(_pattern(today, "alpha", "x")) + (company / "patterns" / "CANDIDATE-x.md").write_text( + _pattern(today, "alpha", "x"), encoding="utf-8" + ) cache_path = company / "patterns" / ".usage-cache.json" # Seed a stale cache that claims alpha mentions a phantom slug. - cache_path.write_text(json.dumps({ - "version": 2, - "computed_at": "1970-01-01T00:00:00+00:00", - "slug_set": ["x"], - "projects": {"alpha": {"mtime": 9_999_999_999.0, "matched_slugs": ["x", "phantom"]}}, - })) + cache_path.write_text( + json.dumps( + { + "version": 2, + "computed_at": "1970-01-01T00:00:00+00:00", + "slug_set": ["x"], + "projects": { + "alpha": {"mtime": 9_999_999_999.0, "matched_slugs": ["x", "phantom"]} + }, + } + ), + encoding="utf-8", + ) review_patterns(company, use_cache=False) - refreshed = json.loads(cache_path.read_text()) + refreshed = json.loads(cache_path.read_text(encoding="utf-8")) # Cache was overwritten; phantom is gone. assert "phantom" not in refreshed["projects"]["alpha"]["matched_slugs"] @@ -252,17 +298,25 @@ def test_use_cache_false_forces_rescan(company: Path) -> None: def test_cache_rejects_old_version(company: Path) -> None: """A v1 cache (from earlier socrates) is treated as missing.""" import json + _make_build(company, "alpha") today = _dt.date.today().isoformat() - (company / "patterns" / "CANDIDATE-x.md").write_text(_pattern(today, "alpha", "x")) + (company / "patterns" / "CANDIDATE-x.md").write_text( + _pattern(today, "alpha", "x"), encoding="utf-8" + ) cache_path = company / "patterns" / ".usage-cache.json" - cache_path.write_text(json.dumps({ - "version": 1, - "computed_at": "1970-01-01T00:00:00+00:00", - "max_input_mtime": 9_999_999_999.0, - "usage": {"x": ["alpha", "phantom-from-v1"]}, - })) + cache_path.write_text( + json.dumps( + { + "version": 1, + "computed_at": "1970-01-01T00:00:00+00:00", + "max_input_mtime": 9_999_999_999.0, + "usage": {"x": ["alpha", "phantom-from-v1"]}, + } + ), + encoding="utf-8", + ) review_patterns(company) - refreshed = json.loads(cache_path.read_text()) + refreshed = json.loads(cache_path.read_text(encoding="utf-8")) assert refreshed["version"] == 2 assert "phantom-from-v1" not in str(refreshed) diff --git a/tests/test_render.py b/tests/test_render.py index 94ed056..e6e0263 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -44,12 +44,12 @@ def test_render_writes_every_file(tmp_path: Path) -> None: written = render_all(target, _sample_answers()) # Spot-check a handful that exercise the various renderers. - assert (target / "AGENTS.md").read_text().startswith("# AGENTS.md") + assert (target / "AGENTS.md").read_text(encoding="utf-8").startswith("# AGENTS.md") assert (target / "CLAUDE.md").is_file() assert (target / "CODEX.md").is_file() - assert (target / "README.md").read_text().startswith("# quarterly-rebates") - assert "Acme Wholesale" in (target / "AGENTS.md").read_text() - assert "Acme Wholesale" in (target / "README.md").read_text() + assert (target / "README.md").read_text(encoding="utf-8").startswith("# quarterly-rebates") + assert "Acme Wholesale" in (target / "AGENTS.md").read_text(encoding="utf-8") + assert "Acme Wholesale" in (target / "README.md").read_text(encoding="utf-8") assert len(written) >= 15 @@ -58,7 +58,7 @@ def test_render_propagates_client_terminology(tmp_path: Path) -> None: scaffold(target) render_all(target, _sample_answers()) - domain = (target / "planning/DOMAIN.md").read_text() + domain = (target / "planning/DOMAIN.md").read_text(encoding="utf-8") assert "rebate cycle" in domain assert "tier A vendor" in domain assert "Operations manager" in domain @@ -92,7 +92,7 @@ def test_render_handles_empty_optional_lists(tmp_path: Path) -> None: # Should not raise. render_all(target, answers) # Empty inputs render the placeholder strings, not 'None' or Python repr. - domain = (target / "planning/DOMAIN.md").read_text() + domain = (target / "planning/DOMAIN.md").read_text(encoding="utf-8") assert "None" not in domain assert "[]" not in domain @@ -100,10 +100,11 @@ def test_render_handles_empty_optional_lists(tmp_path: Path) -> None: def test_decisions_md_stamps_each_decision_with_today(tmp_path: Path) -> None: """Rendered decisions get a trailing (YYYY-MM-DD) so timeline can find them.""" import datetime as _dt + target = tmp_path / "demo" scaffold(target) render_all(target, _sample_answers()) - body = (target / "planning" / "DECISIONS.md").read_text() + body = (target / "planning" / "DECISIONS.md").read_text(encoding="utf-8") today = _dt.date.today().isoformat() # Both seeded decisions should carry today's stamp. assert f"Supabase over self-hosted Postgres — client already has an account ({today})" in body @@ -115,6 +116,8 @@ def test_sprint1_handoff_includes_sprint_folder_placeholder(tmp_path: Path) -> N scaffold(target) render_all(target, _sample_answers()) - handoff = (target / "planning/sprints/001-discovery-architecture/handoff-prompt.md").read_text() + handoff = (target / "planning/sprints/001-discovery-architecture/handoff-prompt.md").read_text( + encoding="utf-8" + ) assert "[SPRINT_FOLDER]" in handoff assert "AGENTS.md" in handoff diff --git a/tests/test_ship.py b/tests/test_ship.py index 832152e..a3ed6ea 100644 --- a/tests/test_ship.py +++ b/tests/test_ship.py @@ -16,15 +16,31 @@ def project(tmp_path: Path) -> Path: p = tmp_path / "demo" scaffold(p) - render_all(p, { - "project_name": "demo", "client": "Acme", "tagline": "t", - "business_goal": "g", "tech_stack": "Python", - "users": ["op"], "current_process": "m", "terminology": [], - "business_rules": [], "decisions": [], "out_of_scope": [], - "risks": ["r"], "fragile_inputs": "", "open_questions": [], - "sprint1_goal": "", "sprint1_acceptance": ["ok"], - "sprint1_inspect": [], "state_current": "", "state_next": "", "state_blockers": [], - }) + render_all( + p, + { + "project_name": "demo", + "client": "Acme", + "tagline": "t", + "business_goal": "g", + "tech_stack": "Python", + "users": ["op"], + "current_process": "m", + "terminology": [], + "business_rules": [], + "decisions": [], + "out_of_scope": [], + "risks": ["r"], + "fragile_inputs": "", + "open_questions": [], + "sprint1_goal": "", + "sprint1_acceptance": ["ok"], + "sprint1_inspect": [], + "state_current": "", + "state_next": "", + "state_blockers": [], + }, + ) return p @@ -36,7 +52,7 @@ def test_preflight_runs_all_four_checks(project: Path) -> None: def test_preflight_journal_passes_when_today_entry_exists(project: Path) -> None: today = _dt.date.today().isoformat() - (project / "planning" / "journal" / f"{today}.md").write_text("entry") + (project / "planning" / "journal" / f"{today}.md").write_text("entry", encoding="utf-8") findings = preflight(project) journal = next(f for f in findings if f.name == "journal") assert journal.result is CheckResult.PASS @@ -57,7 +73,7 @@ def test_preflight_extract_warns_when_no_pattern(project: Path) -> None: def test_preflight_extract_passes_when_pattern_present(project: Path) -> None: (project / "patterns").mkdir(exist_ok=True) (project / "patterns" / "CANDIDATE-x.md").write_text( - f"**Source project** | `{project.name}`\n" + f"**Source project** | `{project.name}`\n", encoding="utf-8" ) findings = preflight(project) extract = next(f for f in findings if f.name == "extract") @@ -67,7 +83,7 @@ def test_preflight_extract_passes_when_pattern_present(project: Path) -> None: def test_preflight_state_warns_on_old_date(project: Path) -> None: old = (_dt.date.today() - _dt.timedelta(days=30)).isoformat() (project / "planning" / "STATE.md").write_text( - f"# STATE\n\n_Last updated: {old}_\n" + f"# STATE\n\n_Last updated: {old}_\n", encoding="utf-8" ) findings = preflight(project) state = next(f for f in findings if f.name == "state") diff --git a/tests/test_status.py b/tests/test_status.py index 797c718..6c6705e 100644 --- a/tests/test_status.py +++ b/tests/test_status.py @@ -16,15 +16,31 @@ def _populate_build(builds_dir: Path, name: str, *, tagline: str = "demo") -> Path: project = builds_dir / name scaffold(project) - render_all(project, { - "project_name": name, "client": "Acme", "tagline": tagline, - "business_goal": "g", "tech_stack": "Python", - "users": [], "current_process": "", "terminology": [], - "business_rules": [], "decisions": [], "out_of_scope": [], - "risks": [], "fragile_inputs": "", "open_questions": [], - "sprint1_goal": "", "sprint1_acceptance": [], - "sprint1_inspect": [], "state_current": "", "state_next": "", "state_blockers": [], - }) + render_all( + project, + { + "project_name": name, + "client": "Acme", + "tagline": tagline, + "business_goal": "g", + "tech_stack": "Python", + "users": [], + "current_process": "", + "terminology": [], + "business_rules": [], + "decisions": [], + "out_of_scope": [], + "risks": [], + "fragile_inputs": "", + "open_questions": [], + "sprint1_goal": "", + "sprint1_acceptance": [], + "sprint1_inspect": [], + "state_current": "", + "state_next": "", + "state_blockers": [], + }, + ) return project @@ -54,7 +70,7 @@ def test_status_reports_state_freshness(company: Path) -> None: project = _populate_build(company / "builds", "alpha") state = project / "planning" / "STATE.md" old = (_dt.date.today() - _dt.timedelta(days=20)).isoformat() - state.write_text(f"# STATE\n\n_Last updated: {old}_\n") + state.write_text(f"# STATE\n\n_Last updated: {old}_\n", encoding="utf-8") rows = companyos_status(company) assert rows[0].state_age_days == 20 @@ -63,7 +79,7 @@ def test_status_reports_journal_freshness(company: Path) -> None: project = _populate_build(company / "builds", "alpha") journal_dir = project / "planning" / "journal" old = _dt.date.today() - _dt.timedelta(days=3) - (journal_dir / f"{old.isoformat()}.md").write_text("entry") + (journal_dir / f"{old.isoformat()}.md").write_text("entry", encoding="utf-8") rows = companyos_status(company) assert rows[0].journal_age_days == 3 diff --git a/tests/test_timeline.py b/tests/test_timeline.py index f4b65fc..69659f4 100644 --- a/tests/test_timeline.py +++ b/tests/test_timeline.py @@ -16,15 +16,31 @@ def project(tmp_path: Path) -> Path: p = tmp_path / "demo" scaffold(p) - render_all(p, { - "project_name": "demo", "client": "Acme", "tagline": "t", - "business_goal": "g", "tech_stack": "Python", - "users": [], "current_process": "", "terminology": [], - "business_rules": [], "decisions": [], "out_of_scope": [], - "risks": [], "fragile_inputs": "", "open_questions": [], - "sprint1_goal": "", "sprint1_acceptance": [], - "sprint1_inspect": [], "state_current": "", "state_next": "", "state_blockers": [], - }) + render_all( + p, + { + "project_name": "demo", + "client": "Acme", + "tagline": "t", + "business_goal": "g", + "tech_stack": "Python", + "users": [], + "current_process": "", + "terminology": [], + "business_rules": [], + "decisions": [], + "out_of_scope": [], + "risks": [], + "fragile_inputs": "", + "open_questions": [], + "sprint1_goal": "", + "sprint1_acceptance": [], + "sprint1_inspect": [], + "state_current": "", + "state_next": "", + "state_blockers": [], + }, + ) return p @@ -32,7 +48,7 @@ def test_timeline_includes_journal_entries(project: Path) -> None: journal = project / "planning" / "journal" yesterday = _dt.date.today() - _dt.timedelta(days=1) (journal / f"{yesterday.isoformat()}.md").write_text( - "# Journal\n\nFixed the parser bug.\n" + "# Journal\n\nFixed the parser bug.\n", encoding="utf-8" ) events = build_timeline(project) journal_events = [e for e in events if e.kind is EventKind.JOURNAL] @@ -53,7 +69,8 @@ def test_timeline_extracts_dated_decisions(project: Path) -> None: decisions.write_text( "## Decisions captured\n\n" "- **Supabase over Postgres — client preference (2026-04-01)**\n" - "- An undated decision should NOT appear in the timeline.\n" + "- An undated decision should NOT appear in the timeline.\n", + encoding="utf-8", ) events = build_timeline(project) decision_events = [e for e in events if e.kind is EventKind.DECISION] @@ -66,8 +83,8 @@ def test_timeline_sorts_chronologically(project: Path) -> None: journal = project / "planning" / "journal" days_ago_3 = _dt.date.today() - _dt.timedelta(days=3) days_ago_1 = _dt.date.today() - _dt.timedelta(days=1) - (journal / f"{days_ago_3.isoformat()}.md").write_text("older") - (journal / f"{days_ago_1.isoformat()}.md").write_text("newer") + (journal / f"{days_ago_3.isoformat()}.md").write_text("older", encoding="utf-8") + (journal / f"{days_ago_1.isoformat()}.md").write_text("newer", encoding="utf-8") events = build_timeline(project) dates = [e.date for e in events if e.kind is EventKind.JOURNAL] assert dates == sorted(dates) @@ -80,7 +97,9 @@ def test_format_timeline_empty() -> None: def test_format_timeline_renders_events(project: Path) -> None: yesterday = _dt.date.today() - _dt.timedelta(days=1) - (project / "planning" / "journal" / f"{yesterday.isoformat()}.md").write_text("note") + (project / "planning" / "journal" / f"{yesterday.isoformat()}.md").write_text( + "note", encoding="utf-8" + ) events = build_timeline(project) text = format_timeline(events, use_color=False) assert yesterday.isoformat() in text