feat: make fix-title agentic

This commit is contained in:
wantsong 2026-06-23 12:48:43 +08:00
parent 7e19041048
commit 19c84ffc8c
10 changed files with 766 additions and 262 deletions

View File

@ -14,7 +14,7 @@ Existing CCPE content is not migrated here.
| Skill | Purpose | Source | Status | Install Policy | Installed Path | CCPE Registration | | 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 | | `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 | | `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 | | `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 | | `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 |

76
scripts/quick_validate.py Normal file
View File

@ -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 <skill-directory>", 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:]))

View File

@ -20,7 +20,7 @@ conda activate skills-vault
## Test ## Test
```powershell ```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 ## Install To Public Skill Surface

View File

@ -1,52 +1,148 @@
--- ---
name: fix-title 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 # 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. Optional inputs:
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.
## 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 Example user-facing request:
python scripts/fix_markdown_titles.py path/to/reply.md
python scripts/fix_markdown_titles.py path/to/reply.md --levels 1 ```text
python scripts/fix_markdown_titles.py path/to/reply.md --levels 3 --dry-run Use $fix-title with mode=artifact on:
python scripts/fix_markdown_titles.py path/to/reply.md --levels 2 --output path/to/fixed.md - 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 <skill-path> to repair Markdown heading hierarchy.
Mode: <discussion|artifact>
Output directory: <output-dir>
Files:
- <absolute-path-1>
- <absolute-path-2>
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
<stem>.heading-map.json
<stem>.heading-plan.json
<stem>.fixed.md
<stem>.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 ```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.

View File

@ -1,3 +1,3 @@
display_name: Fix Markdown Titles display_name: "Fix Title"
short_description: Shift Markdown headings down before inserting pasted chat replies. short_description: "Agentic repair for Markdown heading trees."
default_prompt: Fix the Markdown heading levels in this file so it can be pasted under a parent section. default_prompt: "Use $fix-title with files=[...] and mode=artifact or mode=discussion to repair Markdown heading hierarchy without overwriting sources."

View File

@ -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 ```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.

View File

@ -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:]))

View File

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

View File

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

View File

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