From 19c84ffc8c2c8e69b1a89b40f18fc2ea05940474 Mon Sep 17 00:00:00 2001 From: wantsong Date: Tue, 23 Jun 2026 12:48:43 +0800 Subject: [PATCH] feat: make fix-title agentic --- registry/skills-index.md | 2 +- scripts/quick_validate.py | 76 ++++ skills/fix-title/INSTALL.md | 2 +- skills/fix-title/SKILL.md | 166 ++++++-- skills/fix-title/agents/openai.yaml | 6 +- skills/fix-title/manual-test/README.md | 14 +- .../fix-title/scripts/fix_markdown_titles.py | 116 ------ .../scripts/semantic_heading_repair.py | 354 ++++++++++++++++++ .../tests/test_fix_markdown_titles.py | 100 ----- .../tests/test_semantic_heading_repair.py | 192 ++++++++++ 10 files changed, 766 insertions(+), 262 deletions(-) create mode 100644 scripts/quick_validate.py delete mode 100644 skills/fix-title/scripts/fix_markdown_titles.py create mode 100644 skills/fix-title/scripts/semantic_heading_repair.py delete mode 100644 skills/fix-title/tests/test_fix_markdown_titles.py create mode 100644 skills/fix-title/tests/test_semantic_heading_repair.py diff --git a/registry/skills-index.md b/registry/skills-index.md index 9bb0479..750c194 100644 --- a/registry/skills-index.md +++ b/registry/skills-index.md @@ -14,7 +14,7 @@ Existing CCPE content is not migrated here. | Skill | Purpose | Source | Status | Install Policy | Installed Path | CCPE Registration | | --- | --- | --- | --- | --- | --- | --- | | `bundle-zip` | Create zip archives from explicit file lists while preserving source-relative paths and validating entries by readback. | `C:\Users\wangq\Documents\Codex\skills-vault\skills\bundle-zip` | `installed` | `default` | `C:\Users\wangq\.agents\skills\bundle-zip` | none | -| `fix-title` | Shift Markdown ATX heading depth for pasted LLM replies before insertion under parent sections. | `C:\Users\wangq\Documents\Codex\knowledge-vault\skills\fix-title` | `installed` | `default` | `C:\Users\wangq\.agents\skills\fix-title` | none | +| `fix-title` | Agentic semantic repair for copied GPT/LLM Markdown heading hierarchy across one or more discussion or artifact files, with fixed copies, heading plans, and reports. | `C:\Users\wangq\Documents\Codex\skills-vault\skills\fix-title` | `installed` | `default` | `C:\Users\wangq\.agents\skills\fix-title` | none | | `lifecycle-status-guard-scan` | Scan configured Markdown, JSON, YAML, and text files for lifecycle/status overclaim candidates and missing local evidence markers. | `C:\Users\wangq\Documents\Codex\skills-vault\skills\lifecycle-status-guard-scan` | `installed` | `default` | `C:\Users\wangq\.agents\skills\lifecycle-status-guard-scan` | none | | `repair-markdown-citations` | Repair ChatGPT/Deep Research Markdown citation tokens into standard footnotes and a deduplicated reference section using exact turn-ID metadata mapping. | `C:\Users\wangq\Documents\Codex\skills-vault\skills\repair-markdown-citations` | `installed` | `default` | `C:\Users\wangq\.agents\skills\repair-markdown-citations` | none | | `regression-validation-gate-runner` | Dry-run or execute project-declared regression and validation gates from a manifest while capturing logs, exit codes, durations, skipped gates, and changed-file notes. | `C:\Users\wangq\Documents\Codex\skills-vault\skills\regression-validation-gate-runner` | `installed` | `default` | `C:\Users\wangq\.agents\skills\regression-validation-gate-runner` | none | diff --git a/scripts/quick_validate.py b/scripts/quick_validate.py new file mode 100644 index 0000000..4e97d01 --- /dev/null +++ b/scripts/quick_validate.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""Quick validation for a Codex skill directory.""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +import yaml + + +MAX_SKILL_NAME_LENGTH = 64 + + +def validate_skill(skill_path: Path) -> tuple[bool, str]: + skill_md = skill_path / "SKILL.md" + if not skill_md.exists(): + return False, "SKILL.md not found" + + content = skill_md.read_text(encoding="utf-8") + if not content.startswith("---"): + return False, "No YAML frontmatter found" + + match = re.match(r"^---\n(.*?)\n---", content, re.DOTALL) + if not match: + return False, "Invalid frontmatter format" + + try: + frontmatter = yaml.safe_load(match.group(1)) + except yaml.YAMLError as exc: + return False, f"Invalid YAML in frontmatter: {exc}" + + if not isinstance(frontmatter, dict): + return False, "Frontmatter must be a YAML dictionary" + + allowed_keys = {"name", "description"} + unexpected_keys = set(frontmatter) - allowed_keys + if unexpected_keys: + return False, "Unexpected frontmatter key(s): " + ", ".join( + sorted(unexpected_keys) + ) + + name = frontmatter.get("name") + if not isinstance(name, str) or not name.strip(): + return False, "Missing or invalid 'name' in frontmatter" + if not re.fullmatch(r"[a-z0-9-]+", name): + return False, "Name must use lowercase letters, digits, and hyphens only" + if name.startswith("-") or name.endswith("-") or "--" in name: + return False, "Name cannot start/end with hyphen or contain consecutive hyphens" + if len(name) > MAX_SKILL_NAME_LENGTH: + return False, f"Name exceeds {MAX_SKILL_NAME_LENGTH} characters" + + description = frontmatter.get("description") + if not isinstance(description, str) or not description.strip(): + return False, "Missing or invalid 'description' in frontmatter" + if "<" in description or ">" in description: + return False, "Description cannot contain angle brackets" + if len(description) > 1024: + return False, "Description exceeds 1024 characters" + + return True, "Skill is valid!" + + +def main(argv: list[str]) -> int: + if len(argv) != 1: + print("Usage: python scripts/quick_validate.py ", file=sys.stderr) + return 2 + + valid, message = validate_skill(Path(argv[0])) + print(message) + return 0 if valid else 1 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/skills/fix-title/INSTALL.md b/skills/fix-title/INSTALL.md index c9ebe80..45fa070 100644 --- a/skills/fix-title/INSTALL.md +++ b/skills/fix-title/INSTALL.md @@ -20,7 +20,7 @@ conda activate skills-vault ## Test ```powershell -python -m unittest discover -s skills\fix-title\tests -v +conda run -n skills-vault python -B -m unittest discover -s skills\fix-title\tests -v ``` ## Install To Public Skill Surface diff --git a/skills/fix-title/SKILL.md b/skills/fix-title/SKILL.md index f233fdf..fca01a5 100644 --- a/skills/fix-title/SKILL.md +++ b/skills/fix-title/SKILL.md @@ -1,52 +1,148 @@ --- name: fix-title -description: Use when a pasted ChatGPT or LLM Markdown reply has heading levels that need to be shifted down before insertion under a parent section such as "## GPT"; also use for Markdown title repair, heading nesting fixes, ATX header adjustment, and adding one or more # levels to headings in a file. +description: "Use when copied ChatGPT/LLM Markdown has semantically wrong heading hierarchy, especially discussion records with round headings and GPT reply blocks, or intake/artifact drafts with repeated accidental top-level headings. Agentic Markdown heading repair for one or more files: accept a file path array plus mode discussion or artifact, prefer subagent/thread isolation for large repairs, preserve originals, and produce fixed copies, heading plans, and reports." --- # Fix Title -## Overview +## Agentic Input -Fix Markdown heading depth for copied LLM replies before inserting them into a discussion record. Use the bundled script for deterministic file edits instead of asking the model to rewrite the document. +Use this skill from an agent prompt, not as a Python CLI. -The default operation adds two heading levels, turning `# Title` into `### Title`, which fits content placed under a parent heading like `## GPT`. +Required inputs: -## Workflow +- `files`: one or more Markdown file paths. +- `mode`: `discussion` or `artifact`. -1. Confirm the input is a Markdown file containing the raw reply to adjust. -2. Choose the number of levels to add. Default to `2` unless the user specifies another value. -3. Run `scripts/fix_markdown_titles.py` on the file. -4. If the target is important or the requested level shift is unusual, use `--dry-run` or `--output` first and inspect the result. -5. Report the path modified and the level shift used. +Optional inputs: -## Commands +- `output_dir`: output folder. If omitted, use repo-local `tmp/fix-title-output` when available; otherwise use `fix-title-output` beside the first source file. +- `execution`: `auto`, `subagent`, `thread`, or `inline`. Default to `auto`. -```bash -python scripts/fix_markdown_titles.py path/to/reply.md -python scripts/fix_markdown_titles.py path/to/reply.md --levels 1 -python scripts/fix_markdown_titles.py path/to/reply.md --levels 3 --dry-run -python scripts/fix_markdown_titles.py path/to/reply.md --levels 2 --output path/to/fixed.md +Example user-facing request: + +```text +Use $fix-title with mode=artifact on: +- C:\path\one.md +- C:\path\two.md +Output to C:\path\tmp\fix-title-output ``` -On Windows PowerShell, quote paths that contain spaces: +## Execution Isolation + +Prefer isolation because semantic repair consumes context and does not depend on the caller's broader conversation. + +- `subagent`: Use when the host supports delegated agents. Pass only the skill path, input files, mode, output directory, and output contract. +- `thread`: Use when the host supports child/background threads and the user explicitly asks for Thread/sub-session isolation or the batch is large enough to risk bloating the main context. +- `inline`: Use only when no subagent/thread mechanism is available or the file is small. +- `auto`: Choose `thread` when explicitly requested, otherwise choose `subagent` when available, otherwise `inline`. + +The caller must verify returned files and reports. Do not treat a subagent/thread message alone as proof of repair. + +## Delegation Prompt + +Send this shape to the isolated worker: + +```text +Use $fix-title at to repair Markdown heading hierarchy. + +Mode: +Output directory: +Files: +- +- + +Rules: +- Read source files as UTF-8. +- Do not overwrite originals. +- Preserve non-heading content. +- Build heading maps, write explicit JSON heading plans, apply only planned heading edits, and write reports. +- Return the fixed file, heading-map, heading-plan, and report paths for every input file. +- Flag ambiguous appended artifacts or uncertain heading relationships instead of silently making creative decisions. +``` + +For Codex specifically, use `spawn_agent` for subagent isolation. Use a Codex Thread only when the user requests thread/sub-session execution or host policy permits it for the current task. + +## Modes + +### Discussion + +Use when a file records conversation rounds, such as `# 1`, `# 2`, `## 任务`, `## 指令`, `## GPT`, or `## GPT的回复`. + +- Preserve outer record structure. +- Treat each GPT reply block as a child of its marker heading. +- Repair headings inside the GPT reply so top-level GPT content starts one level deeper than the GPT marker, usually `###`. +- Repair descendants recursively by semantic relationship, not by raw hash count. + +Example: under `## GPT`, raw `# 1.xxxx`, raw `# 2.xxx`, and raw `## 3.xxx` may all become `###` when they are peer sections in the GPT reply. + +### Artifact + +Use when copied GPT output is stored as a standalone planning/intake/artifact document. + +- Treat the first visible `#` heading as the document title by default. +- Repair later accidental top-level headings under that root. +- Preserve one clear document title. +- If a later heading is actually an appended second artifact, do not silently merge it; mark it in the report for human review. + +Example: after a root title, raw sibling headings `# 1.xxxx`, `# 2.xxx`, and `## 3.xxx` may all become `##` when they are peer sections. + +## Output Contract + +For each source file, write: + +```text +.heading-map.json +.heading-plan.json +.fixed.md +.heading-report.md +``` + +For batch runs, also write: + +```text +fix-title-batch-report.md +``` + +The plan JSON is the audit boundary. Every changed heading must include: + +```json +{ + "line": 25, + "from_level": 1, + "to_level": 3, + "reason": "This is a top-level section inside the GPT reply block under ## GPT." +} +``` + +## Internal Helper + +Use `scripts/semantic_heading_repair.py` only as an internal importable helper. It is not a user-facing CLI. + +Useful helper API: + +- `inspect_payload(path)` +- `write_inspection(source, output, output_format="json")` +- `prepare_batch_scaffold(files, mode, output_dir)` +- `apply_plan(source, plan, output, report)` + +The helper protects fenced code blocks, quoted headings, and indented code. It does not make semantic decisions. + +## Repair Rules + +- Never overwrite the source file unless the user explicitly asks for replacement. +- Do not mechanically add or subtract the same number of heading levels across a whole file. +- Do not change non-heading content. +- Do not change headings inside fenced code blocks, block quotes, or indented code. +- Do not treat Markdown numbering as decisive. `## 3.xxx` can be a sibling of `# 1.xxx` when context shows they are peers. +- When a parent heading is repaired, re-evaluate descendants recursively. +- If unsure, preserve content and record the ambiguity in the report. + +## Validation + +Run after editing this skill: ```powershell -python .\scripts\fix_markdown_titles.py "C:\path with spaces\reply.md" --levels 2 +conda run -n skills-vault python -B -m unittest discover -s skills\fix-title\tests -v +conda run -n skills-vault python -B scripts\quick_validate.py skills\fix-title ``` - -## Behavior - -| Input | `--levels 2` output | -| --- | --- | -| `# A` | `### A` | -| `## B` | `#### B` | -| `###### C` | `###### C` | - -The script only changes ATX headings: lines beginning with up to three spaces followed by `#` through `######` and then whitespace. It does not change headings inside fenced code blocks, quoted lines such as `> # text`, or indented code. - -## Common Mistakes - -- Do not use a broad replace like `#` to `###`; it will corrupt code blocks, quoted content, tags, and inline text. -- Do not increase headings past level six; Markdown ATX headings stop at `######`. -- Do not change the recorder's outer headings such as `# 1` or `## GPT` unless the user explicitly points the skill at the recorder file and requests that behavior. -- Do not use prompt rewriting for this task when the script can perform the edit directly. diff --git a/skills/fix-title/agents/openai.yaml b/skills/fix-title/agents/openai.yaml index f67e912..d52d4ee 100644 --- a/skills/fix-title/agents/openai.yaml +++ b/skills/fix-title/agents/openai.yaml @@ -1,3 +1,3 @@ -display_name: Fix Markdown Titles -short_description: Shift Markdown headings down before inserting pasted chat replies. -default_prompt: Fix the Markdown heading levels in this file so it can be pasted under a parent section. +display_name: "Fix Title" +short_description: "Agentic repair for Markdown heading trees." +default_prompt: "Use $fix-title with files=[...] and mode=artifact or mode=discussion to repair Markdown heading hierarchy without overwriting sources." diff --git a/skills/fix-title/manual-test/README.md b/skills/fix-title/manual-test/README.md index 32e6e69..88d8d8f 100644 --- a/skills/fix-title/manual-test/README.md +++ b/skills/fix-title/manual-test/README.md @@ -1,13 +1,15 @@ -# Manual Test Input +# Manual Test -Place one raw Markdown file here for manual validation. +Use this directory for small manual validation files when needed. -Recommended filename: +Recommended files: ```text -raw-reply.md +raw-discussion.md +raw-artifact.md +heading-plan.json ``` -The file should be an original pasted LLM reply before heading repair. +The raw files should preserve original copied LLM/GPT Markdown before heading repair. -After the file is added, run the Skill against a copy or with `--output` first, then compare the result before updating the installed version. +Invoke the Skill through an agent prompt with `files=[...]`, `mode=discussion|artifact`, and an explicit `output_dir`. Do not overwrite the source during manual validation. diff --git a/skills/fix-title/scripts/fix_markdown_titles.py b/skills/fix-title/scripts/fix_markdown_titles.py deleted file mode 100644 index f75fe92..0000000 --- a/skills/fix-title/scripts/fix_markdown_titles.py +++ /dev/null @@ -1,116 +0,0 @@ -#!/usr/bin/env python3 -"""Shift Markdown ATX headings down by a fixed number of levels.""" - -from __future__ import annotations - -import argparse -import pathlib -import re -import sys - - -HEADING_RE = re.compile(r"^( {0,3})(#{1,6})([ \t]+.*)$") -FENCE_RE = re.compile(r"^( {0,3})(`{3,}|~{3,})") - - -def shift_headings(text: str, levels: int) -> str: - if levels <= 0: - raise ValueError("levels must be a positive integer") - - lines = text.splitlines(keepends=True) - in_fence = False - fence_marker = "" - output: list[str] = [] - - for line in lines: - body = line[:-1] if line.endswith("\n") else line - newline = "\n" if line.endswith("\n") else "" - if body.endswith("\r"): - body = body[:-1] - newline = "\r" + newline - - fence_match = FENCE_RE.match(body) - if fence_match: - marker = fence_match.group(2) - marker_char = marker[0] - if not in_fence: - in_fence = True - fence_marker = marker_char * len(marker) - elif marker_char == fence_marker[0] and len(marker) >= len(fence_marker): - in_fence = False - fence_marker = "" - output.append(line) - continue - - if in_fence: - output.append(line) - continue - - heading_match = HEADING_RE.match(body) - if heading_match: - indent, hashes, rest = heading_match.groups() - new_level = min(6, len(hashes) + levels) - output.append(f"{indent}{'#' * new_level}{rest}{newline}") - else: - output.append(line) - - return "".join(output) - - -def parse_args(argv: list[str]) -> argparse.Namespace: - parser = argparse.ArgumentParser( - description="Shift Markdown ATX headings down by adding # characters." - ) - parser.add_argument("path", type=pathlib.Path, help="Markdown file to rewrite") - parser.add_argument( - "-l", - "--levels", - type=int, - default=2, - help="Number of heading levels to add. Default: 2", - ) - parser.add_argument( - "-o", - "--output", - type=pathlib.Path, - help="Write to this file instead of modifying the source file", - ) - parser.add_argument( - "--dry-run", - action="store_true", - help="Print converted content to stdout without writing files", - ) - return parser.parse_args(argv) - - -def main(argv: list[str]) -> int: - args = parse_args(argv) - - if args.levels <= 0: - print("error: --levels must be a positive integer", file=sys.stderr) - return 2 - - try: - source = args.path.read_text(encoding="utf-8") - except OSError as exc: - print(f"error: cannot read {args.path}: {exc}", file=sys.stderr) - return 1 - - fixed = shift_headings(source, args.levels) - - if args.dry_run: - sys.stdout.write(fixed) - return 0 - - destination = args.output or args.path - try: - destination.write_text(fixed, encoding="utf-8") - except OSError as exc: - print(f"error: cannot write {destination}: {exc}", file=sys.stderr) - return 1 - - return 0 - - -if __name__ == "__main__": - raise SystemExit(main(sys.argv[1:])) diff --git a/skills/fix-title/scripts/semantic_heading_repair.py b/skills/fix-title/scripts/semantic_heading_repair.py new file mode 100644 index 0000000..dcdc3b2 --- /dev/null +++ b/skills/fix-title/scripts/semantic_heading_repair.py @@ -0,0 +1,354 @@ +#!/usr/bin/env python3 +"""Internal helpers for semantic Markdown heading repair plans. + +The semantic decision belongs to the agent using this skill. This script keeps +the file rewrite narrow: it only changes ATX heading marker depth at explicit +line numbers from a reviewed plan. +""" + +from __future__ import annotations + +import json +import pathlib +import re +from dataclasses import dataclass +from typing import Any + + +HEADING_RE = re.compile(r"^( {0,3})(#{1,6})([ \t]+)(.*)$") +FENCE_RE = re.compile(r"^( {0,3})(`{3,}|~{3,})") + + +@dataclass(frozen=True) +class Heading: + line: int + level: int + text: str + + +@dataclass(frozen=True) +class Edit: + line: int + from_level: int | None + to_level: int + reason: str + + +def _split_body_newline(line: str) -> tuple[str, str]: + if line.endswith("\r\n"): + return line[:-2], "\r\n" + if line.endswith("\n"): + return line[:-1], "\n" + if line.endswith("\r"): + return line[:-1], "\r" + return line, "" + + +def _clean_heading_text(raw: str) -> str: + text = raw.strip() + closing = re.match(r"^(.*?)(?:[ \t]+#{1,}[ \t]*)$", text) + if closing: + text = closing.group(1).rstrip() + return text + + +def iter_headings(lines: list[str]) -> list[Heading]: + headings: list[Heading] = [] + in_fence = False + fence_char = "" + fence_len = 0 + + for index, line in enumerate(lines, start=1): + body, _newline = _split_body_newline(line) + fence_match = FENCE_RE.match(body) + if fence_match: + marker = fence_match.group(2) + marker_char = marker[0] + if not in_fence: + in_fence = True + fence_char = marker_char + fence_len = len(marker) + elif marker_char == fence_char and len(marker) >= fence_len: + in_fence = False + fence_char = "" + fence_len = 0 + continue + + if in_fence: + continue + + heading_match = HEADING_RE.match(body) + if not heading_match: + continue + + hashes = heading_match.group(2) + text = _clean_heading_text(heading_match.group(4)) + headings.append(Heading(line=index, level=len(hashes), text=text)) + + return headings + + +def inspect_payload(path: pathlib.Path) -> dict[str, Any]: + text = path.read_text(encoding="utf-8") + lines = text.splitlines(keepends=True) + return { + "source": str(path), + "heading_count": len(iter_headings(lines)), + "headings": [ + {"line": item.line, "level": item.level, "text": item.text} + for item in iter_headings(lines) + ], + } + + +def render_inspection(payload: dict[str, Any], output_format: str) -> str: + if output_format == "json": + return json.dumps(payload, ensure_ascii=False, indent=2) + "\n" + if output_format == "markdown": + return format_markdown_inspection(payload) + raise ValueError("output_format must be 'json' or 'markdown'") + + +def write_inspection( + source: pathlib.Path, + output: pathlib.Path, + output_format: str = "markdown", +) -> None: + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text( + render_inspection(inspect_payload(source), output_format), + encoding="utf-8", + ) + + +def format_markdown_inspection(payload: dict[str, Any]) -> str: + output = [ + "# Markdown Heading Inspection", + "", + f"- Source: `{payload['source']}`", + f"- Heading count: {payload['heading_count']}", + "", + "| Line | Level | Text |", + "| --- | --- | --- |", + ] + for heading in payload["headings"]: + output.append( + f"| {heading['line']} | {heading['level']} | {heading['text']} |" + ) + output.append("") + return "\n".join(output) + + +def _unique_stem(path: pathlib.Path, seen: dict[str, int]) -> str: + base = path.stem + count = seen.get(base, 0) + 1 + seen[base] = count + if count == 1: + return base + return f"{base}-{count}" + + +def _batch_item_paths( + source: pathlib.Path, + mode: str, + output_dir: pathlib.Path, + stem: str, +) -> dict[str, str]: + return { + "source": str(source), + "mode": mode, + "heading_map": str(output_dir / f"{stem}.heading-map.json"), + "plan": str(output_dir / f"{stem}.heading-plan.json"), + "fixed": str(output_dir / f"{stem}.fixed.md"), + "report": str(output_dir / f"{stem}.heading-report.md"), + } + + +def prepare_batch_scaffold( + files: list[pathlib.Path], + mode: str, + output_dir: pathlib.Path, +) -> list[dict[str, str]]: + if mode not in {"discussion", "artifact"}: + raise ValueError("mode must be 'discussion' or 'artifact'") + if not files: + raise ValueError("files must contain at least one Markdown path") + + output_dir.mkdir(parents=True, exist_ok=True) + seen: dict[str, int] = {} + items: list[dict[str, str]] = [] + + for source in files: + source = pathlib.Path(source) + if not source.exists(): + raise ValueError(f"source file does not exist: {source}") + stem = _unique_stem(source, seen) + item = _batch_item_paths(source, mode, output_dir, stem) + write_inspection(source, pathlib.Path(item["heading_map"]), "json") + pathlib.Path(item["plan"]).write_text( + json.dumps( + { + "mode": mode, + "source": item["source"], + "output": item["fixed"], + "report": item["report"], + "edits": [], + }, + ensure_ascii=False, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + items.append(item) + + write_batch_report(items, output_dir / "fix-title-batch-report.md") + return items + + +def write_batch_report(items: list[dict[str, str]], output: pathlib.Path) -> None: + lines = [ + "# Fix Title Batch Report", + "", + "| Source | Mode | Heading Map | Plan | Fixed | Report |", + "| --- | --- | --- | --- | --- | --- |", + ] + for item in items: + lines.append( + "| {source} | {mode} | {heading_map} | {plan} | {fixed} | {report} |".format( + **item + ) + ) + lines.append("") + output.write_text("\n".join(lines), encoding="utf-8") + + +def parse_plan(plan_path: pathlib.Path) -> tuple[str, list[Edit]]: + try: + payload = json.loads(plan_path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise ValueError(f"invalid JSON plan {plan_path}: {exc}") from exc + + if not isinstance(payload, dict): + raise ValueError("plan must be a JSON object") + + mode = str(payload.get("mode", "unspecified")) + raw_edits = payload.get("edits") + if not isinstance(raw_edits, list): + raise ValueError("plan must contain an edits list") + + edits: list[Edit] = [] + seen_lines: set[int] = set() + for index, raw_edit in enumerate(raw_edits, start=1): + if not isinstance(raw_edit, dict): + raise ValueError(f"edit {index} must be an object") + line = raw_edit.get("line") + from_level = raw_edit.get("from_level") + to_level = raw_edit.get("to_level") + reason = raw_edit.get("reason", "") + + if not isinstance(line, int) or line <= 0: + raise ValueError(f"edit {index} has invalid line") + if line in seen_lines: + raise ValueError(f"duplicate edit for line {line}") + seen_lines.add(line) + + if from_level is not None and ( + not isinstance(from_level, int) or not 1 <= from_level <= 6 + ): + raise ValueError(f"edit {index} has invalid from_level") + if not isinstance(to_level, int) or not 1 <= to_level <= 6: + raise ValueError(f"edit {index} has invalid to_level") + if not isinstance(reason, str): + raise ValueError(f"edit {index} has invalid reason") + + edits.append( + Edit( + line=line, + from_level=from_level, + to_level=to_level, + reason=reason.strip(), + ) + ) + + return mode, edits + + +def apply_plan( + source: pathlib.Path, + plan: pathlib.Path, + output: pathlib.Path, + report: pathlib.Path | None, +) -> int: + lines = source.read_text(encoding="utf-8").splitlines(keepends=True) + mode, edits = parse_plan(plan) + headings_by_line = {heading.line: heading for heading in iter_headings(lines)} + changed: list[dict[str, Any]] = [] + + for edit in edits: + heading = headings_by_line.get(edit.line) + if heading is None: + raise ValueError(f"line {edit.line} is not an editable ATX heading") + if edit.from_level is not None and heading.level != edit.from_level: + raise ValueError( + f"line {edit.line} expected level {edit.from_level}, " + f"found level {heading.level}" + ) + + body, newline = _split_body_newline(lines[edit.line - 1]) + heading_match = HEADING_RE.match(body) + if heading_match is None: + raise ValueError(f"line {edit.line} is not an editable ATX heading") + + indent, _hashes, spacing, rest = heading_match.groups() + lines[edit.line - 1] = f"{indent}{'#' * edit.to_level}{spacing}{rest}{newline}" + changed.append( + { + "line": edit.line, + "from_level": heading.level, + "to_level": edit.to_level, + "text": heading.text, + "reason": edit.reason, + } + ) + + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text("".join(lines), encoding="utf-8") + + if report is not None: + report.parent.mkdir(parents=True, exist_ok=True) + report.write_text( + format_report(source, output, plan, mode, changed), + encoding="utf-8", + ) + + return len(changed) + + +def format_report( + source: pathlib.Path, + output: pathlib.Path, + plan: pathlib.Path, + mode: str, + changed: list[dict[str, Any]], +) -> str: + lines = [ + "# Semantic Heading Repair Report", + "", + f"- Source: `{source}`", + f"- Output: `{output}`", + f"- Plan: `{plan}`", + f"- Mode: `{mode}`", + f"- Changed headings: {len(changed)}", + "", + "| Line | From | To | Heading | Reason |", + "| --- | --- | --- | --- | --- |", + ] + for item in changed: + lines.append( + "| {line} | {from_level} | {to_level} | {text} | {reason} |".format( + **item + ) + ) + lines.append("") + return "\n".join(lines) diff --git a/skills/fix-title/tests/test_fix_markdown_titles.py b/skills/fix-title/tests/test_fix_markdown_titles.py deleted file mode 100644 index 340e12f..0000000 --- a/skills/fix-title/tests/test_fix_markdown_titles.py +++ /dev/null @@ -1,100 +0,0 @@ -import pathlib -import subprocess -import sys -import tempfile -import unittest - - -SCRIPT = pathlib.Path(__file__).resolve().parents[1] / "scripts" / "fix_markdown_titles.py" - - -def run_cli(*args): - return subprocess.run( - [sys.executable, str(SCRIPT), *args], - text=True, - capture_output=True, - check=False, - ) - - -class FixMarkdownTitlesTest(unittest.TestCase): - def test_shifts_atx_headings_and_leaves_code_blocks(self): - with tempfile.TemporaryDirectory() as tmp: - target = pathlib.Path(tmp) / "reply.md" - target.write_text( - "\n".join( - [ - "Intro", - "", - "# One", - "## Two ##", - "```md", - "# Not a heading", - "```", - "> # quoted content", - " # indented code", - "###### Six", - "", - ] - ), - encoding="utf-8", - ) - - result = run_cli(str(target), "--levels", "2") - - self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual( - target.read_text(encoding="utf-8"), - "\n".join( - [ - "Intro", - "", - "### One", - "#### Two ##", - "```md", - "# Not a heading", - "```", - "> # quoted content", - " # indented code", - "###### Six", - "", - ] - ), - ) - - def test_dry_run_prints_without_writing(self): - with tempfile.TemporaryDirectory() as tmp: - target = pathlib.Path(tmp) / "reply.md" - target.write_text("# One\n", encoding="utf-8") - - result = run_cli(str(target), "--levels", "1", "--dry-run") - - self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual(result.stdout, "## One\n") - self.assertEqual(target.read_text(encoding="utf-8"), "# One\n") - - def test_output_writes_to_separate_file(self): - with tempfile.TemporaryDirectory() as tmp: - source = pathlib.Path(tmp) / "reply.md" - output = pathlib.Path(tmp) / "fixed.md" - source.write_text("# One\n", encoding="utf-8") - - result = run_cli(str(source), "--levels", "3", "--output", str(output)) - - self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual(source.read_text(encoding="utf-8"), "# One\n") - self.assertEqual(output.read_text(encoding="utf-8"), "#### One\n") - - def test_rejects_non_positive_levels(self): - with tempfile.TemporaryDirectory() as tmp: - target = pathlib.Path(tmp) / "reply.md" - target.write_text("# One\n", encoding="utf-8") - - result = run_cli(str(target), "--levels", "0") - - self.assertNotEqual(result.returncode, 0) - self.assertIn("positive integer", result.stderr) - - -if __name__ == "__main__": - unittest.main() diff --git a/skills/fix-title/tests/test_semantic_heading_repair.py b/skills/fix-title/tests/test_semantic_heading_repair.py new file mode 100644 index 0000000..0568448 --- /dev/null +++ b/skills/fix-title/tests/test_semantic_heading_repair.py @@ -0,0 +1,192 @@ +import importlib.util +import json +import pathlib +import sys +import tempfile +import unittest + + +SCRIPT = ( + pathlib.Path(__file__).resolve().parents[1] + / "scripts" + / "semantic_heading_repair.py" +) + +SPEC = importlib.util.spec_from_file_location("semantic_heading_repair", SCRIPT) +repair = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +sys.modules[SPEC.name] = repair +SPEC.loader.exec_module(repair) + + +class SemanticHeadingRepairTest(unittest.TestCase): + def test_inspect_reports_only_real_atx_headings(self): + with tempfile.TemporaryDirectory() as tmp: + source = pathlib.Path(tmp) / "source.md" + source.write_text( + "\n".join( + [ + "# Title", + "", + "## Section", + "```md", + "# Not a heading", + "```", + "> # quoted content", + " # indented code", + "### Child", + "", + ] + ), + encoding="utf-8", + ) + + payload = repair.inspect_payload(source) + + self.assertEqual( + [(item["line"], item["level"], item["text"]) for item in payload["headings"]], + [(1, 1, "Title"), (3, 2, "Section"), (9, 3, "Child")], + ) + + def test_write_inspection_file_uses_utf8(self): + with tempfile.TemporaryDirectory() as tmp: + source = pathlib.Path(tmp) / "source.md" + output = pathlib.Path(tmp) / "heading-map.json" + source.write_text("# 标题\n", encoding="utf-8") + + repair.write_inspection(source, output, output_format="json") + + payload = json.loads(output.read_text(encoding="utf-8")) + self.assertEqual(payload["headings"][0]["text"], "标题") + + def test_apply_plan_rewrites_only_requested_heading_levels(self): + with tempfile.TemporaryDirectory() as tmp: + source = pathlib.Path(tmp) / "source.md" + output = pathlib.Path(tmp) / "fixed.md" + report = pathlib.Path(tmp) / "report.md" + plan = pathlib.Path(tmp) / "plan.json" + source.write_text( + "\n".join( + [ + "# 1", + "", + "## GPT", + "", + "# Wrong parent", + "", + "## Wrong child", + "", + "```md", + "# Not a heading", + "```", + "", + ] + ), + encoding="utf-8", + ) + plan.write_text( + json.dumps( + { + "mode": "discussion", + "edits": [ + { + "line": 5, + "from_level": 1, + "to_level": 3, + "reason": "GPT reply content belongs under ## GPT.", + }, + { + "line": 7, + "from_level": 2, + "to_level": 4, + "reason": "Child heading follows the repaired parent.", + }, + ], + }, + ensure_ascii=False, + ), + encoding="utf-8", + ) + + changed = repair.apply_plan(source, plan, output, report) + + self.assertEqual(changed, 2) + self.assertEqual( + output.read_text(encoding="utf-8"), + "\n".join( + [ + "# 1", + "", + "## GPT", + "", + "### Wrong parent", + "", + "#### Wrong child", + "", + "```md", + "# Not a heading", + "```", + "", + ] + ), + ) + report_text = report.read_text(encoding="utf-8") + self.assertIn("| 5 | 1 | 3 | Wrong parent |", report_text) + self.assertIn("GPT reply content belongs under ## GPT.", report_text) + + def test_apply_plan_rejects_stale_or_wrong_source_plans(self): + with tempfile.TemporaryDirectory() as tmp: + source = pathlib.Path(tmp) / "source.md" + plan = pathlib.Path(tmp) / "plan.json" + source.write_text("# Title\n\n## Section\n", encoding="utf-8") + plan.write_text( + json.dumps( + { + "edits": [ + { + "line": 3, + "from_level": 1, + "to_level": 3, + "reason": "This plan was made for another source.", + } + ] + }, + ensure_ascii=False, + ), + encoding="utf-8", + ) + + with self.assertRaisesRegex(ValueError, "expected level 1"): + repair.apply_plan(source, plan, pathlib.Path(tmp) / "fixed.md", None) + + def test_prepare_batch_scaffold_accepts_file_array(self): + with tempfile.TemporaryDirectory() as tmp: + root = pathlib.Path(tmp) + first = root / "one.md" + second = root / "two.md" + output_dir = root / "out" + first.write_text("# One\n\n# Bad\n", encoding="utf-8") + second.write_text("# Two\n\n## Good\n", encoding="utf-8") + + items = repair.prepare_batch_scaffold( + [first, second], + mode="artifact", + output_dir=output_dir, + ) + + self.assertEqual(len(items), 2) + for item in items: + self.assertEqual(item["mode"], "artifact") + self.assertTrue(pathlib.Path(item["heading_map"]).exists()) + self.assertTrue(pathlib.Path(item["plan"]).exists()) + self.assertTrue(str(item["fixed"]).endswith(".fixed.md")) + plan_payload = json.loads(pathlib.Path(item["plan"]).read_text(encoding="utf-8")) + self.assertEqual(plan_payload["mode"], "artifact") + self.assertEqual(plan_payload["edits"], []) + + batch_report = output_dir / "fix-title-batch-report.md" + self.assertIn("one.md", batch_report.read_text(encoding="utf-8")) + + +if __name__ == "__main__": + unittest.main()