Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,13 @@ subprojects:
- vendor/service-b/docs-project.yaml
```

Configured paths are contained to the directory that owns each
`docs-project.yaml` by default. If a docs project lives in `./docs`, its
`docs_roots`, folders, indexes, and subproject references cannot escape
`./docs` with `../` paths. Use `subprojects` from a parent config to include
other docs roots, or set `security.allow_external_paths: true` only for a
trusted legacy layout that intentionally crosses that boundary.

Generated projects include `docs-project.schema.json` next to
`docs-project.yaml` for editor validation and prompt-based config authoring.
The same schema is published at
Expand Down
42 changes: 35 additions & 7 deletions docuchango/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,27 @@
from __future__ import annotations

import sys
from collections import deque
from pathlib import Path

import click
import yaml
from rich.console import Console

from docuchango import __version__
from docuchango.config_paths import resolve_config_path
from docuchango.schemas import DocsProjectConfig

console = Console()


def _load_docs_project_config(root: Path) -> tuple[DocsProjectConfig | None, Path | None]:
"""Load docs-project.yaml from repo root or docs-cms."""
candidates = [root / "docs-project.yaml", root / "docs-cms" / "docs-project.yaml"]
candidates = [
root / "docs-project.yaml",
root / "docs-cms" / "docs-project.yaml",
root / "docs" / "docs-project.yaml",
]
return _load_docs_project_config_from_candidates(candidates)


Expand All @@ -47,13 +53,16 @@ def _iter_docs_project_configs(root: Path) -> list[tuple[DocsProjectConfig, Path

configs = [(config, config_path)]
seen = {config_path.resolve()}
pending = [(config, config_path)]
pending = deque([(config, config_path)])

while pending:
parent_config, parent_path = pending.pop(0)
parent_config, parent_path = pending.popleft()
parent_base = parent_path.parent
allow_external_paths = parent_config.security.allow_external_paths
for subproject in parent_config.subprojects:
sub_path = (parent_base / subproject.path).resolve()
sub_path = resolve_config_path(parent_base, subproject.path, parent_base, allow_external_paths)
if not sub_path:
continue
if sub_path.is_dir():
sub_path = sub_path / "docs-project.yaml"
if sub_path in seen:
Expand Down Expand Up @@ -82,14 +91,19 @@ def _discover_doc_files(root: Path) -> list[Path]:
if not config.structure:
continue
config_base = config_path.parent
allow_external_paths = config.security.allow_external_paths

if config.structure.doc_types:
roots = config.structure.docs_roots or ["."]
for doc_type_cfg in config.structure.doc_types.values():
for root_rel in roots:
root_path = (config_base / root_rel).resolve()
root_path = resolve_config_path(config_base, root_rel, config_base, allow_external_paths)
if not root_path:
continue
for folder in doc_type_cfg.folders:
folder_path = (root_path / folder).resolve()
folder_path = resolve_config_path(root_path, folder, root_path, allow_external_paths)
if not folder_path:
continue
if not folder_path.exists():
continue
for file_path in folder_path.rglob("*.md"):
Expand All @@ -98,7 +112,9 @@ def _discover_doc_files(root: Path) -> list[Path]:
all_files.append(file_path)
else:
for folder in config.structure.document_folders:
folder_path = (config_base / folder).resolve()
folder_path = resolve_config_path(config_base, folder, config_base, allow_external_paths)
if not folder_path:
continue
if not folder_path.exists():
continue
for file_path in folder_path.rglob("*.md"):
Expand Down Expand Up @@ -673,22 +689,34 @@ def bulk_update(
console.print("[red]Error: --set requires FIELD=VALUE format[/red]")
sys.exit(1)
field_name, value = set_field.split("=", 1)
if not field_name:
console.print("[red]Error: --set requires a non-empty field name[/red]")
sys.exit(1)
operation = "set"
elif add_field:
if "=" not in add_field:
console.print("[red]Error: --add requires FIELD=VALUE format[/red]")
sys.exit(1)
field_name, value = add_field.split("=", 1)
if not field_name:
console.print("[red]Error: --add requires a non-empty field name[/red]")
sys.exit(1)
operation = "add"
elif remove_field:
field_name = remove_field
if not field_name:
console.print("[red]Error: --remove requires a non-empty field name[/red]")
sys.exit(1)
value = None
operation = "remove"
elif rename_field:
if "=" not in rename_field:
console.print("[red]Error: --rename requires OLD=NEW format[/red]")
sys.exit(1)
field_name, value = rename_field.split("=", 1)
if not field_name or not value:
console.print("[red]Error: --rename requires non-empty OLD and NEW field names[/red]")
sys.exit(1)
operation = "rename"

# Find files to process
Expand Down
24 changes: 24 additions & 0 deletions docuchango/config_paths.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""Path containment helpers for docs-project.yaml configuration."""

from __future__ import annotations

from pathlib import Path


def is_within_path(path: Path, boundary: Path) -> bool:
"""Return true when path resolves inside boundary or is boundary itself."""
resolved_path = path.resolve()
resolved_boundary = boundary.resolve()
try:
resolved_path.relative_to(resolved_boundary)
return True
except ValueError:
return False


def resolve_config_path(base_dir: Path, path_value: str, boundary: Path, allow_external_paths: bool) -> Path | None:
"""Resolve a config-relative path, enforcing containment unless explicitly allowed."""
resolved = (base_dir / path_value).resolve()
if allow_external_paths or is_within_path(resolved, boundary):
return resolved
return None
11 changes: 8 additions & 3 deletions docuchango/fixes/bulk_update.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ def should_skip_file(file_path: Path) -> bool:
return file_path.name == "index.md"


def serialize_frontmatter(metadata: dict) -> str:
def serialize_frontmatter(metadata: dict[str, object]) -> str:
"""Serialize frontmatter dict back to YAML with proper formatting.

Args:
Expand Down Expand Up @@ -76,6 +76,9 @@ def update_frontmatter_bulk(
# Validate operation
if operation not in VALID_OPERATIONS:
raise ValueError(f"Invalid operation '{operation}'. Must be one of: {', '.join(VALID_OPERATIONS)}")
rename_target = new_value
if operation == "rename" and not rename_target:
raise ValueError("Rename operation requires a non-empty new field name")

try:
post = frontmatter.loads(content)
Expand Down Expand Up @@ -128,9 +131,11 @@ def update_frontmatter_bulk(
elif operation == "rename":
# Rename field (field_name is old_name, new_value is new_name)
if field_name in post.metadata:
post.metadata[new_value] = post.metadata.pop(field_name)
if rename_target is None:
raise ValueError("Rename operation requires a non-empty new field name")
post.metadata[rename_target] = post.metadata.pop(field_name)
modified = True
message = f"Renamed {field_name} → {new_value}"
message = f"Renamed {field_name} → {rename_target}"
else:
message = f"Field {field_name} not found"

Expand Down
66 changes: 30 additions & 36 deletions docuchango/readability.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,22 @@ def extract_paragraphs(self, markdown_content: str) -> list[tuple[str, int]]:
in_html_comment = False
frontmatter_count = 0

def flush_current_paragraph() -> None:
nonlocal current_paragraph
if not current_paragraph:
return
para_text = " ".join(current_paragraph).strip()
if len(para_text) >= self.config.min_paragraph_length:
paragraphs.append((para_text, current_paragraph_start_line))
current_paragraph = []

def is_void_html_tag_line(stripped_line: str) -> bool:
lower_line = stripped_line.lower()
return any(
lower_line == f"<{tag}>" or lower_line.startswith(f"<{tag} ")
for tag in ("br", "hr", "img", "input", "meta", "link")
)

for i, line in enumerate(lines, start=1):
stripped = line.strip()

Expand All @@ -185,12 +201,8 @@ def extract_paragraphs(self, markdown_content: str) -> list[tuple[str, int]]:
# Track code blocks
if stripped.startswith("```"):
in_code_block = not in_code_block
# Flush current paragraph when entering code block
if in_code_block and current_paragraph:
para_text = " ".join(current_paragraph).strip()
if len(para_text) >= self.config.min_paragraph_length:
paragraphs.append((para_text, current_paragraph_start_line))
current_paragraph = []
if in_code_block:
flush_current_paragraph()
continue

if in_code_block:
Expand All @@ -199,38 +211,28 @@ def extract_paragraphs(self, markdown_content: str) -> list[tuple[str, int]]:
# Track HTML comments (multi-line support)
if "<!--" in stripped:
in_html_comment = True
# Flush current paragraph
if current_paragraph:
para_text = " ".join(current_paragraph).strip()
if len(para_text) >= self.config.min_paragraph_length:
paragraphs.append((para_text, current_paragraph_start_line))
current_paragraph = []
flush_current_paragraph()

if in_html_comment:
if "-->" in stripped:
in_html_comment = False
continue

# Track HTML/MDX blocks (opening and closing tags)
if stripped.startswith("<"):
# Flush current paragraph when entering HTML block
if current_paragraph:
para_text = " ".join(current_paragraph).strip()
if len(para_text) >= self.config.min_paragraph_length:
paragraphs.append((para_text, current_paragraph_start_line))
current_paragraph = []

# Check if this is a self-closing tag or opening tag
if not stripped.endswith("/>"):
in_html_block = True
continue

# Check if we're closing an HTML block
if in_html_block:
if stripped.startswith("</") or stripped.endswith(">"):
in_html_block = False
continue

# Track HTML/MDX blocks (opening and closing tags)
if stripped.startswith("<"):
flush_current_paragraph()

is_single_line_tag = stripped.endswith("/>") or "</" in stripped or is_void_html_tag_line(stripped)
if not is_single_line_tag:
in_html_block = True
continue

# Skip headings, lists, empty lines, blockquotes
if (
stripped.startswith("#")
Expand All @@ -240,12 +242,7 @@ def extract_paragraphs(self, markdown_content: str) -> list[tuple[str, int]]:
or stripped.startswith(">") # blockquotes
or not stripped
):
# Flush current paragraph
if current_paragraph:
para_text = " ".join(current_paragraph).strip()
if len(para_text) >= self.config.min_paragraph_length:
paragraphs.append((para_text, current_paragraph_start_line))
current_paragraph = []
flush_current_paragraph()
continue

# Start new paragraph if this is the first line
Expand All @@ -256,10 +253,7 @@ def extract_paragraphs(self, markdown_content: str) -> list[tuple[str, int]]:
current_paragraph.append(stripped)

# Flush final paragraph
if current_paragraph:
para_text = " ".join(current_paragraph).strip()
if len(para_text) >= self.config.min_paragraph_length:
paragraphs.append((para_text, current_paragraph_start_line))
flush_current_paragraph()

return paragraphs

Expand Down
16 changes: 16 additions & 0 deletions docuchango/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,18 @@ def to_readability_config(self): # type: ignore[no-untyped-def]
)


class DocsProjectSecurity(BaseModel):
"""Security controls for docs-project.yaml path resolution."""

allow_external_paths: bool = Field(
default=False,
description=(
"Allow configured document paths to resolve outside this docs-project.yaml directory. "
"Keep false for normal projects; use subprojects to include other documentation roots."
),
)


class DocsProjectIndexTimeBucket(BaseModel):
"""Time or milestone bucket rules for a document index."""

Expand Down Expand Up @@ -328,6 +340,10 @@ class DocsProjectConfig(BaseModel):
default_factory=DocsProjectReadability,
description="Readability analysis configuration and thresholds",
)
security: DocsProjectSecurity = Field(
default_factory=DocsProjectSecurity,
description="Security controls for config-relative path resolution",
)
indexes: list[DocsProjectIndex] = Field(
default_factory=list,
description="Markdown index files with stricter content discipline",
Expand Down
12 changes: 12 additions & 0 deletions docuchango/templates/docs-project.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,18 @@
},
"default": []
},
"security": {
"type": "object",
"additionalProperties": false,
"properties": {
"allow_external_paths": {
"type": "boolean",
"default": false,
"description": "Allow configured document paths to resolve outside this docs-project.yaml directory. Prefer subprojects for additional docs roots."
}
},
"default": {"allow_external_paths": false}
},
"indexes": {
"type": "array",
"items": {
Expand Down
5 changes: 5 additions & 0 deletions docuchango/templates/docs-project.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ structure:
# my-custom-standard: "^custom-.+\\.md$"
# date-prefix: "^\\d{6}-.+必$"

security:
# Keep configured paths inside this docs-project.yaml directory by default.
# Use subprojects to include other docs roots instead of ../ path escapes.
allow_external_paths: false

# Optional subproject docs configs. Paths are relative to this file and can point
# to either a subproject directory or its docs-project.yaml file.
# subprojects:
Expand Down
Loading