Skip to content
Open
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
5 changes: 4 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ jobs:
- name: Check dependencies for vulnerabilities
run: |
uv pip install pip-audit
uv run pip-audit
# Ignore CVEs with no fix available:
# - CVE-2026-34444 (lupa): transitive dep from pydocket→fakeredis[lua]
# - CVE-2026-3219 (pip): pip-audit's own dependency
uv run pip-audit --ignore-vuln CVE-2026-34444 --ignore-vuln CVE-2026-3219

- name: Run linting
run: uv run ruff check
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,6 @@ dist/

# Test reports
report.html

# Hypothesis (property-based testing) cache
.hypothesis/
12 changes: 9 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,19 @@ classifiers = [
"Programming Language :: Python :: 3.13",
]
dependencies = [
"authlib>=1.6.11", # Security: CVE-2026-28802, CVE-2026-27962, CVE-2026-28490, GHSA-jj8c-mmj3-mmgv
"click>=8.3.1",
"cryptography>=46.0.5", # Security fix: CVE-2026-26007
"cryptography>=46.0.7", # Security: CVE-2026-34073, CVE-2026-39892
"fastapi>=0.115.0",
"fastmcp>=3.2.0,<4",
"pathspec>=1.0.3",
"pydocket>=0.17.2,<0.19", # Required by fastmcp 2.14.7
"pydocket>=0.17.2,<0.19", # Required by fastmcp
"pygments>=2.20.0", # Security: CVE-2026-4539
"pyjwt>=2.12.0", # Security: CVE-2026-32597
"python-dotenv>=1.2.2", # Security: CVE-2026-28684
"python-multipart>=0.0.26", # Security: CVE-2026-40347
"pyyaml>=6.0",
"requests>=2.33.0", # Security: CVE-2026-25645
"uvicorn>=0.30.0",
]

Expand All @@ -38,7 +44,7 @@ dev = [
"asciidoc-linter @ git+https://github.com/docToolchain/asciidoc-linter.git",
"hypothesis>=6.0.0",
"pre-commit>=4.0.0",
"pytest>=8.0.0",
"pytest>=9.0.3", # Security: CVE-2025-71176
"pytest-asyncio>=0.24.0",
"pytest-cov>=5.0.0",
"pytest-html>=4.0.0",
Expand Down
14 changes: 11 additions & 3 deletions src/dacli/asciidoc_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,15 +129,17 @@ class AsciidocStructureParser:
max_include_depth: Maximum depth for nested includes (default: 20)
"""

def __init__(self, base_path: Path, max_include_depth: int = 20):
def __init__(self, base_path: Path, max_include_depth: int = 20, namespace: str | None = None):
"""Initialize the parser.

Args:
base_path: Base path for resolving relative file paths
max_include_depth: Maximum depth for nested includes
namespace: Optional namespace prefix for multi-root mode (ADR-014)
"""
self.base_path = base_path
self.max_include_depth = max_include_depth
self.namespace = namespace

@staticmethod
def scan_includes(file_path: Path) -> set[Path]:
Expand Down Expand Up @@ -189,20 +191,26 @@ def _get_file_prefix(self, file_path: Path) -> str:
The file prefix is the relative path from base_path to file_path,
without the file extension. This ensures unique paths across documents.

In multi-root mode (ADR-014), the namespace is prepended with a colon
separator, producing paths like 'namespace:file/path:section'.

Issue #266: Only strips known extensions (.md, .adoc) to preserve dots
in filenames (e.g. version numbers like "report_v1.2.3.adoc").

Args:
file_path: Path to the document being parsed

Returns:
Relative path without extension (e.g., "guides/installation")
Relative path without extension, optionally namespace-prefixed
"""
try:
relative = file_path.relative_to(self.base_path)
except ValueError:
relative = Path(file_path.name)
return strip_doc_extension(relative)
prefix = strip_doc_extension(relative)
if self.namespace is not None:
return f"{self.namespace}:{prefix}"
return prefix

def get_section(self, doc: AsciidocDocument, path: str) -> Section | None:
"""Get a section by its hierarchical path.
Expand Down
152 changes: 134 additions & 18 deletions src/dacli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@
from dacli.asciidoc_parser import AsciidocStructureParser
from dacli.file_handler import FileReadError, FileSystemHandler, FileWriteError
from dacli.markdown_parser import MarkdownStructureParser
from dacli.index_builder import build_index
from dacli.index_builder import build_index, count_descendants, find_root_for_file
from dacli.models import DocumentRoot, detect_document_role
from dacli.root_config import RootConfigError, resolve_roots
from dacli.services import (
compute_hash,
get_project_metadata,
Expand Down Expand Up @@ -140,11 +142,12 @@ def _get_section_append_line(
"el": "elements",
"val": "validate",
"lv": "sections-at-level",
"ns": "namespaces",
}

# Command groups for organized help output (story-based ordering)
COMMAND_GROUPS = {
"Discover": ["structure", "metadata"],
"Discover": ["namespaces", "structure", "metadata"],
"Find": ["search", "sections-at-level"],
"Read": ["section", "elements"],
"Validate": ["validate"],
Expand Down Expand Up @@ -265,14 +268,31 @@ class CliContext:

def __init__(
self,
docs_root: Path,
output_format: str,
pretty: bool,
roots: list[DocumentRoot] | Path | None = None,
output_format: str = "text",
pretty: bool = False,
verbose: bool = False,
respect_gitignore: bool = True,
include_hidden: bool = False,
*,
docs_root: Path | None = None,
):
self.docs_root = docs_root
# Backward compat: accept docs_root kwarg or Path as first arg
if isinstance(roots, Path):
docs_root = roots
roots = None
if roots is None:
if docs_root is None:
docs_root = Path.cwd()
self.roots = [DocumentRoot(
name=docs_root.name,
path=docs_root.resolve(),
mode="workspace",
)]
else:
self.roots = roots
# Backward compat: first root is the primary docs_root
self.docs_root = self.roots[0].path
self.output_format = output_format
self.pretty = pretty
self.verbose = verbose
Expand All @@ -286,15 +306,11 @@ def __init__(

self.index = StructureIndex()
self.file_handler = FileSystemHandler()
self.asciidoc_parser = AsciidocStructureParser(base_path=docs_root)
self.markdown_parser = MarkdownStructureParser(base_path=docs_root)

# Build index
# Build index from all roots
build_index(
docs_root,
self.roots,
self.index,
self.asciidoc_parser,
self.markdown_parser,
respect_gitignore=respect_gitignore,
include_hidden=include_hidden,
)
Expand Down Expand Up @@ -353,8 +369,23 @@ def _format_as_text(data: dict, indent: int = 0) -> str:
@click.option(
"--docs-root",
type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path),
default=Path.cwd(),
help="Documentation root directory (default: current directory)",
default=None,
help="Documentation root directory (default: current directory). "
"Cannot be combined with --workspace/--reference.",
)
@click.option(
"--workspace",
multiple=True,
metavar="name=X,path=Y[,type=Z]",
help="Read-write documentation root (repeatable). "
"Required keys: name, path. Optional: type.",
)
@click.option(
"--reference",
multiple=True,
metavar="name=X,path=Y[,type=Z]",
help="Read-only documentation root (repeatable). "
"Required keys: name, path. Optional: type.",
)
@click.option(
"--format",
Expand Down Expand Up @@ -392,7 +423,9 @@ def _format_as_text(data: dict, indent: int = 0) -> str:
@click.pass_context
def cli(
ctx,
docs_root: Path,
docs_root: Path | None,
workspace: tuple[str, ...],
reference: tuple[str, ...],
output_format: str,
pretty: bool,
verbose: bool,
Expand All @@ -404,8 +437,17 @@ def cli(
Access documentation structure, content, and metadata from the command line.
Designed for LLM integration via bash/shell commands.
"""
try:
roots = resolve_roots(
workspaces=list(workspace) if workspace else None,
references=list(reference) if reference else None,
docs_root=docs_root,
)
except RootConfigError as e:
raise click.UsageError(str(e)) from e

ctx.obj = CliContext(
docs_root,
roots,
output_format,
pretty,
verbose,
Expand All @@ -414,6 +456,53 @@ def cli(
)


@cli.command(
epilog="""
Examples:
dacli namespaces # List all documentation sources
dacli --format json ns # JSON output using alias
"""
)
@pass_context
def namespaces(ctx: CliContext):
"""List available documentation namespaces (ADR-014).

Shows all documentation roots with their access mode (workspace/reference),
framework type, and the documents they contain with roles and formats.
"""
ns_list = []
for root in ctx.roots:
root_docs = []
for doc in ctx.index._documents:
try:
doc.file_path.resolve().relative_to(root.path)
except ValueError:
continue
ext = doc.file_path.suffix.lower()
doc_format = "asciidoc" if ext in (".adoc", ".asciidoc") else "markdown"
section_count = len(doc.sections)
for s in doc.sections:
section_count += count_descendants(s)
root_docs.append({
"slug": doc.file_path.stem.lower(),
"role": detect_document_role(doc.file_path),
"format": doc_format,
"sections": section_count,
})
ns_list.append({
"name": root.name,
"type": root.doc_type,
"mode": root.mode,
"documents": root_docs,
})

result = {
"namespaces": ns_list,
"total_namespaces": len(ns_list),
}
click.echo(format_output(ctx, result))


@cli.command(
epilog="""
Examples:
Expand Down Expand Up @@ -452,7 +541,10 @@ def section(ctx: CliContext, path: str):
normalized_path = path.lstrip("/")

# Check for path format issues (Issue #198)
corrected_path, had_extra_colons = ctx.index.normalize_path(normalized_path)
multi_root = len(ctx.roots) > 1
corrected_path, had_extra_colons = ctx.index.normalize_path(
normalized_path, multi_root=multi_root,
)

section_obj = ctx.index.get_section(normalized_path)
if section_obj is None:
Expand Down Expand Up @@ -728,7 +820,7 @@ def metadata(ctx: CliContext, path: str | None):
@pass_context
def validate(ctx: CliContext):
"""Validate the document structure."""
result = service_validate_structure(ctx.index, ctx.docs_root)
result = service_validate_structure(ctx.index, [r.path for r in ctx.roots])
click.echo(format_output(ctx, result))

if not result["valid"]:
Expand All @@ -755,6 +847,19 @@ def update(
if not processed_content.strip():
click.echo("Warning: Section content will be cleared.", err=True)

# ADR-014: Check access mode before write
section_obj = ctx.index.get_section(path)
if section_obj is not None:
root = find_root_for_file(ctx.roots, section_obj.source_location.file)
if root is not None and root.mode == "reference":
result = {
"success": False,
"error": f"Cannot modify '{path}': source is mounted as "
f"read-only reference (namespace '{root.name}').",
}
click.echo(format_output(ctx, result))
sys.exit(EXIT_WRITE_ERROR)

result = service_update_section(
index=ctx.index,
file_handler=ctx.file_handler,
Expand Down Expand Up @@ -794,6 +899,17 @@ def insert(ctx: CliContext, path: str, position: str, content: str):
click.echo(format_output(ctx, result))
sys.exit(EXIT_PATH_NOT_FOUND)

# ADR-014: Check access mode before write
root = find_root_for_file(ctx.roots, section_obj.source_location.file)
if root is not None and root.mode == "reference":
result = {
"success": False,
"error": f"Cannot modify '{normalized_path}': source is mounted as "
f"read-only reference (namespace '{root.name}').",
}
click.echo(format_output(ctx, result))
sys.exit(EXIT_WRITE_ERROR)

file_path = section_obj.source_location.file
start_line = section_obj.source_location.line
# Note: end_line is computed dynamically by _get_section_append_line for 'after'/'append'
Expand Down
Loading