Skip to content

Commit 8359fbd

Browse files
rehsackclaude
andcommitted
feat: implement ADR-014 multi-root documentation sources (Phase 1)
Enable dacli to index multiple documentation roots simultaneously with workspace (read-write) and reference (read-only) access modes. This eliminates re-explaining shared context every LLM session and prevents accidental modification of upstream documentation. Namespace-prefixed paths (namespace:file:section) keep cross-root sections unambiguous while single-root mode stays fully backward compatible — all 713 existing tests pass unchanged. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Jens Rehsack <sno@netbsd.org>
1 parent 6f8fda2 commit 8359fbd

12 files changed

Lines changed: 1149 additions & 148 deletions

src/dacli/asciidoc_parser.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -129,15 +129,17 @@ class AsciidocStructureParser:
129129
max_include_depth: Maximum depth for nested includes (default: 20)
130130
"""
131131

132-
def __init__(self, base_path: Path, max_include_depth: int = 20):
132+
def __init__(self, base_path: Path, max_include_depth: int = 20, namespace: str | None = None):
133133
"""Initialize the parser.
134134
135135
Args:
136136
base_path: Base path for resolving relative file paths
137137
max_include_depth: Maximum depth for nested includes
138+
namespace: Optional namespace prefix for multi-root mode (ADR-014)
138139
"""
139140
self.base_path = base_path
140141
self.max_include_depth = max_include_depth
142+
self.namespace = namespace
141143

142144
@staticmethod
143145
def scan_includes(file_path: Path) -> set[Path]:
@@ -189,20 +191,26 @@ def _get_file_prefix(self, file_path: Path) -> str:
189191
The file prefix is the relative path from base_path to file_path,
190192
without the file extension. This ensures unique paths across documents.
191193
194+
In multi-root mode (ADR-014), the namespace is prepended with a colon
195+
separator, producing paths like 'namespace:file/path:section'.
196+
192197
Issue #266: Only strips known extensions (.md, .adoc) to preserve dots
193198
in filenames (e.g. version numbers like "report_v1.2.3.adoc").
194199
195200
Args:
196201
file_path: Path to the document being parsed
197202
198203
Returns:
199-
Relative path without extension (e.g., "guides/installation")
204+
Relative path without extension, optionally namespace-prefixed
200205
"""
201206
try:
202207
relative = file_path.relative_to(self.base_path)
203208
except ValueError:
204209
relative = Path(file_path.name)
205-
return strip_doc_extension(relative)
210+
prefix = strip_doc_extension(relative)
211+
if self.namespace is not None:
212+
return f"{self.namespace}:{prefix}"
213+
return prefix
206214

207215
def get_section(self, doc: AsciidocDocument, path: str) -> Section | None:
208216
"""Get a section by its hierarchical path.

src/dacli/cli.py

Lines changed: 134 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,9 @@
2929
from dacli.asciidoc_parser import AsciidocStructureParser
3030
from dacli.file_handler import FileReadError, FileSystemHandler, FileWriteError
3131
from dacli.markdown_parser import MarkdownStructureParser
32-
from dacli.index_builder import build_index
32+
from dacli.index_builder import build_index, count_descendants, find_root_for_file
33+
from dacli.models import DocumentRoot, detect_document_role
34+
from dacli.root_config import RootConfigError, resolve_roots
3335
from dacli.services import (
3436
compute_hash,
3537
get_project_metadata,
@@ -140,11 +142,12 @@ def _get_section_append_line(
140142
"el": "elements",
141143
"val": "validate",
142144
"lv": "sections-at-level",
145+
"ns": "namespaces",
143146
}
144147

145148
# Command groups for organized help output (story-based ordering)
146149
COMMAND_GROUPS = {
147-
"Discover": ["structure", "metadata"],
150+
"Discover": ["namespaces", "structure", "metadata"],
148151
"Find": ["search", "sections-at-level"],
149152
"Read": ["section", "elements"],
150153
"Validate": ["validate"],
@@ -265,14 +268,31 @@ class CliContext:
265268

266269
def __init__(
267270
self,
268-
docs_root: Path,
269-
output_format: str,
270-
pretty: bool,
271+
roots: list[DocumentRoot] | Path | None = None,
272+
output_format: str = "text",
273+
pretty: bool = False,
271274
verbose: bool = False,
272275
respect_gitignore: bool = True,
273276
include_hidden: bool = False,
277+
*,
278+
docs_root: Path | None = None,
274279
):
275-
self.docs_root = docs_root
280+
# Backward compat: accept docs_root kwarg or Path as first arg
281+
if isinstance(roots, Path):
282+
docs_root = roots
283+
roots = None
284+
if roots is None:
285+
if docs_root is None:
286+
docs_root = Path.cwd()
287+
self.roots = [DocumentRoot(
288+
name=docs_root.name,
289+
path=docs_root.resolve(),
290+
mode="workspace",
291+
)]
292+
else:
293+
self.roots = roots
294+
# Backward compat: first root is the primary docs_root
295+
self.docs_root = self.roots[0].path
276296
self.output_format = output_format
277297
self.pretty = pretty
278298
self.verbose = verbose
@@ -286,15 +306,11 @@ def __init__(
286306

287307
self.index = StructureIndex()
288308
self.file_handler = FileSystemHandler()
289-
self.asciidoc_parser = AsciidocStructureParser(base_path=docs_root)
290-
self.markdown_parser = MarkdownStructureParser(base_path=docs_root)
291309

292-
# Build index
310+
# Build index from all roots
293311
build_index(
294-
docs_root,
312+
self.roots,
295313
self.index,
296-
self.asciidoc_parser,
297-
self.markdown_parser,
298314
respect_gitignore=respect_gitignore,
299315
include_hidden=include_hidden,
300316
)
@@ -353,8 +369,23 @@ def _format_as_text(data: dict, indent: int = 0) -> str:
353369
@click.option(
354370
"--docs-root",
355371
type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path),
356-
default=Path.cwd(),
357-
help="Documentation root directory (default: current directory)",
372+
default=None,
373+
help="Documentation root directory (default: current directory). "
374+
"Cannot be combined with --workspace/--reference.",
375+
)
376+
@click.option(
377+
"--workspace",
378+
multiple=True,
379+
metavar="name=X,path=Y[,type=Z]",
380+
help="Read-write documentation root (repeatable). "
381+
"Required keys: name, path. Optional: type.",
382+
)
383+
@click.option(
384+
"--reference",
385+
multiple=True,
386+
metavar="name=X,path=Y[,type=Z]",
387+
help="Read-only documentation root (repeatable). "
388+
"Required keys: name, path. Optional: type.",
358389
)
359390
@click.option(
360391
"--format",
@@ -392,7 +423,9 @@ def _format_as_text(data: dict, indent: int = 0) -> str:
392423
@click.pass_context
393424
def cli(
394425
ctx,
395-
docs_root: Path,
426+
docs_root: Path | None,
427+
workspace: tuple[str, ...],
428+
reference: tuple[str, ...],
396429
output_format: str,
397430
pretty: bool,
398431
verbose: bool,
@@ -404,8 +437,17 @@ def cli(
404437
Access documentation structure, content, and metadata from the command line.
405438
Designed for LLM integration via bash/shell commands.
406439
"""
440+
try:
441+
roots = resolve_roots(
442+
workspaces=list(workspace) if workspace else None,
443+
references=list(reference) if reference else None,
444+
docs_root=docs_root,
445+
)
446+
except RootConfigError as e:
447+
raise click.UsageError(str(e)) from e
448+
407449
ctx.obj = CliContext(
408-
docs_root,
450+
roots,
409451
output_format,
410452
pretty,
411453
verbose,
@@ -414,6 +456,53 @@ def cli(
414456
)
415457

416458

459+
@cli.command(
460+
epilog="""
461+
Examples:
462+
dacli namespaces # List all documentation sources
463+
dacli --format json ns # JSON output using alias
464+
"""
465+
)
466+
@pass_context
467+
def namespaces(ctx: CliContext):
468+
"""List available documentation namespaces (ADR-014).
469+
470+
Shows all documentation roots with their access mode (workspace/reference),
471+
framework type, and the documents they contain with roles and formats.
472+
"""
473+
ns_list = []
474+
for root in ctx.roots:
475+
root_docs = []
476+
for doc in ctx.index._documents:
477+
try:
478+
doc.file_path.resolve().relative_to(root.path)
479+
except ValueError:
480+
continue
481+
ext = doc.file_path.suffix.lower()
482+
doc_format = "asciidoc" if ext in (".adoc", ".asciidoc") else "markdown"
483+
section_count = len(doc.sections)
484+
for s in doc.sections:
485+
section_count += count_descendants(s)
486+
root_docs.append({
487+
"slug": doc.file_path.stem.lower(),
488+
"role": detect_document_role(doc.file_path),
489+
"format": doc_format,
490+
"sections": section_count,
491+
})
492+
ns_list.append({
493+
"name": root.name,
494+
"type": root.doc_type,
495+
"mode": root.mode,
496+
"documents": root_docs,
497+
})
498+
499+
result = {
500+
"namespaces": ns_list,
501+
"total_namespaces": len(ns_list),
502+
}
503+
click.echo(format_output(ctx, result))
504+
505+
417506
@cli.command(
418507
epilog="""
419508
Examples:
@@ -452,7 +541,10 @@ def section(ctx: CliContext, path: str):
452541
normalized_path = path.lstrip("/")
453542

454543
# Check for path format issues (Issue #198)
455-
corrected_path, had_extra_colons = ctx.index.normalize_path(normalized_path)
544+
multi_root = len(ctx.roots) > 1
545+
corrected_path, had_extra_colons = ctx.index.normalize_path(
546+
normalized_path, multi_root=multi_root,
547+
)
456548

457549
section_obj = ctx.index.get_section(normalized_path)
458550
if section_obj is None:
@@ -728,7 +820,7 @@ def metadata(ctx: CliContext, path: str | None):
728820
@pass_context
729821
def validate(ctx: CliContext):
730822
"""Validate the document structure."""
731-
result = service_validate_structure(ctx.index, ctx.docs_root)
823+
result = service_validate_structure(ctx.index, [r.path for r in ctx.roots])
732824
click.echo(format_output(ctx, result))
733825

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

850+
# ADR-014: Check access mode before write
851+
section_obj = ctx.index.get_section(path)
852+
if section_obj is not None:
853+
root = find_root_for_file(ctx.roots, section_obj.source_location.file)
854+
if root is not None and root.mode == "reference":
855+
result = {
856+
"success": False,
857+
"error": f"Cannot modify '{path}': source is mounted as "
858+
f"read-only reference (namespace '{root.name}').",
859+
}
860+
click.echo(format_output(ctx, result))
861+
sys.exit(EXIT_WRITE_ERROR)
862+
758863
result = service_update_section(
759864
index=ctx.index,
760865
file_handler=ctx.file_handler,
@@ -794,6 +899,17 @@ def insert(ctx: CliContext, path: str, position: str, content: str):
794899
click.echo(format_output(ctx, result))
795900
sys.exit(EXIT_PATH_NOT_FOUND)
796901

902+
# ADR-014: Check access mode before write
903+
root = find_root_for_file(ctx.roots, section_obj.source_location.file)
904+
if root is not None and root.mode == "reference":
905+
result = {
906+
"success": False,
907+
"error": f"Cannot modify '{normalized_path}': source is mounted as "
908+
f"read-only reference (namespace '{root.name}').",
909+
}
910+
click.echo(format_output(ctx, result))
911+
sys.exit(EXIT_WRITE_ERROR)
912+
797913
file_path = section_obj.source_location.file
798914
start_line = section_obj.source_location.line
799915
# Note: end_line is computed dynamically by _get_section_append_line for 'after'/'append'

0 commit comments

Comments
 (0)