diff --git a/AGENTS.md b/AGENTS.md index 9275906..e3c84a1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -87,7 +87,9 @@ You run workflows only when explicitly instructed to do so by a developer or Don Donna is configured to log significant operation steps via `task` tool. -### `depmesh` +Special workflows to use: + +- `@/workflows/polish.donna.md` — format, fix architecture, lint, and test errors. Run it after making changes to the codebase at the moments when the project is expected to be in a working state: between significant implementation steps, before reporting completion of a task, etc. Run this workflow instead of running individual operations, unless you are explicitly needed to run a specific operation for some reason. `depmesh` — a tool for discovering dependencies between project artifacts. diff --git a/README.md b/README.md index b2bf0cb..458a979 100644 --- a/README.md +++ b/README.md @@ -325,6 +325,8 @@ Common built-in operation kinds: - `donna.lib.output` prints information and continues. - `donna.lib.finish` finishes the workflow task. +Workflow text can use directives such as `donna.lib.goto(...)` for transitions, `donna.lib.task_variable(...)` for captured script output, and `donna.lib.path(...)` for normalized project paths. + ## Specifications Project behavior and architecture are specified in [./specs](./specs/). Start with [./specs/intro.md](./specs/intro.md) for the index. diff --git a/changes/next_release.md b/changes/next_release.md new file mode 100644 index 0000000..ad9b927 --- /dev/null +++ b/changes/next_release.md @@ -0,0 +1,9 @@ + +### Changes + +- Added the `donna.lib.path(...)` workflow directive for rendering project file references in agent-facing instructions. + - Relative paths are resolved from the workflow file that contains the directive. + - By default, paths render as root-anchored project paths such as `@/specs/behavior/file_paths.md`. + - Use `mode="absolute"` when an instruction needs a host filesystem path. +- Generalized Donna's project path handling so root-anchored paths can point to any file or directory inside the project, not only workflow artifacts. +- Updated the built-in workflow documentation and RFC workflows to use `donna.lib.path(...)` for stable project file references. diff --git a/donna/cli/types.py b/donna/cli/types.py index e8c2b4f..d43573a 100644 --- a/donna/cli/types.py +++ b/donna/cli/types.py @@ -10,7 +10,6 @@ ARTIFACT_SECTION_DELIMITER, ArtifactId, ArtifactSectionId, - split_artifact_section_id, ) from donna.domain.constants import DONNA_ARTIFACT_EXTENSION from donna.domain.internal_ids import ActionRequestId @@ -38,29 +37,24 @@ def _artifact_filename(value: str) -> str: return pathlib.PurePosixPath(value.split(ARTIFACT_SECTION_DELIMITER, maxsplit=1)[0]).name -def validate_supported_artifact_id(artifact_id: ArtifactId) -> None: - if not has_donna_artifact_extension(_artifact_filename(str(artifact_id))): +def parse_artifact_id_argument(value: str, project_root: PathInput) -> ArtifactId: + artifact_path = workspace_paths.normalize_artifact_path( + value, UntrustedPath(project_root), cwd=UntrustedPath(pathlib.Path.cwd()) + ) + if artifact_path is None: + _exit_with_errors([domain_errors.InvalidIdFormat(id_type=ArtifactId.__name__, value=value)]) + + if not has_donna_artifact_extension(_artifact_filename(artifact_path)): raise typer.BadParameter( - f"Unsupported artifact extension for '{artifact_id}'. Use '*{DONNA_ARTIFACT_EXTENSION}'." + f"Unsupported artifact extension for '{artifact_path}'. Use '*{DONNA_ARTIFACT_EXTENSION}'." ) - -def validate_supported_artifact_section_id(section_id: ArtifactSectionId) -> None: - parts = split_artifact_section_id(section_id) - if parts is None: - raise typer.BadParameter(f"Invalid artifact section id '{section_id}'.") - - validate_supported_artifact_id(parts.artifact_id) - - -def parse_artifact_id_argument(value: str, project_root: PathInput) -> ArtifactId: artifact_id = workspace_paths.normalize_artifact_id( value, UntrustedPath(project_root), cwd=UntrustedPath(pathlib.Path.cwd()) ) if artifact_id is None: _exit_with_errors([domain_errors.InvalidIdFormat(id_type=ArtifactId.__name__, value=value)]) - validate_supported_artifact_id(artifact_id) return artifact_id @@ -71,7 +65,6 @@ def parse_artifact_section_id_argument(value: str, project_root: PathInput) -> A if section_id is None: _exit_with_errors([domain_errors.InvalidIdFormat(id_type=f"{ArtifactSectionId.__name__} format", value=value)]) - validate_supported_artifact_section_id(section_id) return section_id diff --git a/donna/domain/artifact_ids.py b/donna/domain/artifact_ids.py index e977a68..3f50886 100644 --- a/donna/domain/artifact_ids.py +++ b/donna/domain/artifact_ids.py @@ -1,10 +1,10 @@ from __future__ import annotations -import pathlib from typing import NewType -from donna.domain.constants import ARTIFACT_ID_PREFIX -from donna.domain.ids import SectionId, _is_artifact_slug_part +from donna.domain.constants import DONNA_ARTIFACT_EXTENSION +from donna.domain.ids import SectionId +from donna.domain.paths import raw_project_path, validate_project_path_id ArtifactId = NewType("ArtifactId", str) ArtifactSectionId = NewType("ArtifactSectionId", str) @@ -25,30 +25,11 @@ def __init__(self, *, full_id: ArtifactSectionId, artifact_id: ArtifactId, secti self.section_id = section_id -def _raw_artifact_path(value: object) -> str | None: - if not isinstance(value, str) or not value.startswith(ARTIFACT_ID_PREFIX): - return None - - raw = value.removeprefix(ARTIFACT_ID_PREFIX) - if not raw: - return None - - return raw - - def validate_artifact_id(value: object) -> bool: - raw = _raw_artifact_path(value) - if raw is None: - return False - - parts = tuple(raw.split("/")) - if any(part == "" for part in parts): - return False - - if not all(_is_artifact_slug_part(part) for part in parts): + if not validate_project_path_id(value): return False - return bool(pathlib.PurePosixPath(parts[-1]).suffix) + return value.lower().endswith(DONNA_ARTIFACT_EXTENSION) # type: ignore[attr-defined, no-any-return] def validate_artifact_section_id(value: object) -> bool: @@ -57,15 +38,15 @@ def validate_artifact_section_id(value: object) -> bool: def artifact_path_parts(artifact_id: ArtifactId) -> tuple[str, ...]: - raw = _raw_artifact_path(str(artifact_id)) - if raw is None or not validate_artifact_id(str(artifact_id)): + raw = raw_project_path(artifact_id) + if raw is None or not validate_artifact_id(artifact_id): raise ValueError(f"Invalid ArtifactId: {artifact_id}") return tuple(raw.split("/")) def artifact_section_id(artifact_id: ArtifactId, local_id: SectionId | str) -> ArtifactSectionId: - local_id = SectionId(str(local_id)) + local_id = SectionId(local_id) section_id = f"{artifact_id}{ARTIFACT_SECTION_DELIMITER}{local_id}" if not validate_artifact_section_id(section_id): diff --git a/donna/domain/ids.py b/donna/domain/ids.py index 7666a5e..805db6b 100644 --- a/donna/domain/ids.py +++ b/donna/domain/ids.py @@ -10,16 +10,16 @@ TIdentifier = TypeVar("TIdentifier", bound="Identifier") -def _is_artifact_slug_part(part: str) -> bool: - if not part: +def _has_section_id_syntax(value: str) -> bool: + if not value: return False allowed_characters = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-") - if any(character not in allowed_characters for character in part): + if any(character not in allowed_characters for character in value): return False - return any(character not in ".-" for character in part) + return any(character not in ".-" for character in value) class Identifier(str): @@ -79,4 +79,4 @@ def validate(cls, value: object) -> bool: if not isinstance(value, str): return False - return _is_artifact_slug_part(value) + return _has_section_id_syntax(value) diff --git a/donna/domain/paths.py b/donna/domain/paths.py index eb873af..614813c 100644 --- a/donna/domain/paths.py +++ b/donna/domain/paths.py @@ -3,10 +3,36 @@ from pathlib import Path from typing import NewType +from donna.domain.constants import ARTIFACT_ID_PREFIX + ProjectRootPath = NewType("ProjectRootPath", Path) ProjectPathId = NewType("ProjectPathId", str) +ProjectPathRaw = NewType("ProjectPathRaw", str) ProjectConfigPath = NewType("ProjectConfigPath", Path) RelativeProjectPath = NewType("RelativeProjectPath", Path) ResolvedProjectPath = NewType("ResolvedProjectPath", Path) UntrustedPath = NewType("UntrustedPath", Path) PathInput = Path | UntrustedPath | ProjectRootPath | ProjectConfigPath + + +def raw_project_path(value: object) -> ProjectPathRaw | None: + if not isinstance(value, str) or not value.startswith(ARTIFACT_ID_PREFIX): + return None + + raw = value.removeprefix(ARTIFACT_ID_PREFIX) + if not raw: + return None + + return ProjectPathRaw(raw) + + +def validate_project_path_id(value: object) -> bool: + raw = raw_project_path(value) + if raw is None: + return False + + parts = tuple(raw.split("/")) + if any(part in ("", ".", "..") for part in parts): + return False + + return True diff --git a/donna/domain/tests/test_artifact_ids.py b/donna/domain/tests/test_artifact_ids.py index ad339e9..1197987 100644 --- a/donna/domain/tests/test_artifact_ids.py +++ b/donna/domain/tests/test_artifact_ids.py @@ -17,9 +17,12 @@ class TestValidateArtifactId: @pytest.mark.parametrize( "value", [ - "@/README.md", "@/workflows/polish.donna.md", + "@/workflows/POLISH.DONNA.MD", "@/.session/donna/plans/feature.donna.md", + "@/---/polish.donna.md", + "@/workflows/Проектный план.donna.md", + "@/workflows/project plan.donna.md", ], ) def test_valid_canonical_artifact_id(self, value: str) -> None: @@ -33,8 +36,9 @@ def test_valid_canonical_artifact_id(self, value: str) -> None: "@/workflows/../README.md", "@/workflows//polish.donna.md", "@/workflows/", - "@/---/polish.donna.md", "@/README", + "@/README.md", + "@/workflow.md", "/home/user/project/workflows/polish.donna.md", None, ], diff --git a/donna/domain/tests/test_ids.py b/donna/domain/tests/test_ids.py index 0247186..e03d7e4 100644 --- a/donna/domain/tests/test_ids.py +++ b/donna/domain/tests/test_ids.py @@ -58,11 +58,11 @@ def test_pydantic_validation__rejects_invalid_identifier_value(self) -> None: class TestSectionId: @pytest.mark.parametrize("value", ["section", "section-1", "section.name", "section_name"]) - def test_validate__accepts_artifact_slug_part(self, value: str) -> None: + def test_validate__accepts_section_id_syntax(self, value: str) -> None: assert SectionId.validate(value) - @pytest.mark.parametrize("value", ["", "---", "...", "section/id", "section id", None]) - def test_validate__rejects_invalid_artifact_slug_part(self, value: object) -> None: + @pytest.mark.parametrize("value", ["", "---", "...", "section/id", "section id", "секция", None]) + def test_validate__rejects_invalid_section_id_syntax(self, value: object) -> None: assert not SectionId.validate(value) def test_parse__returns_section_id(self) -> None: diff --git a/donna/domain/tests/test_paths.py b/donna/domain/tests/test_paths.py new file mode 100644 index 0000000..181621e --- /dev/null +++ b/donna/domain/tests/test_paths.py @@ -0,0 +1,47 @@ +import pytest + +from donna.domain.paths import ProjectPathRaw, raw_project_path, validate_project_path_id + + +class TestRawProjectPath: + def test_returns_raw_path_without_project_root_prefix(self) -> None: + assert raw_project_path("@/workflows/test.donna.md") == ProjectPathRaw("workflows/test.donna.md") + + @pytest.mark.parametrize("value", ["@", "@/", "workflows/test.donna.md", None]) + def test_rejects_non_root_anchored_project_paths(self, value: object) -> None: + assert raw_project_path(value) is None + + +class TestValidateProjectPathId: + @pytest.mark.parametrize( + "value", + [ + "@/README.md", + "@/README", + "@/LICENSE", + "@/Makefile", + "@/workflows", + "@/workflows/polish.donna.md", + "@/workflows/archive", + "@/src/donna", + "@/workflows/Проектный план", + "@/workflows/project plan.donna.md", + ], + ) + def test_accepts_canonical_project_paths(self, value: str) -> None: + assert validate_project_path_id(value) + + @pytest.mark.parametrize( + "value", + [ + "@", + "@/", + "@/workflows/../README.md", + "@/workflows//polish.donna.md", + "@/workflows/", + "/home/user/project/workflows/polish.donna.md", + None, + ], + ) + def test_rejects_malformed_project_paths(self, value: object) -> None: + assert not validate_project_path_id(value) diff --git a/donna/lib/__init__.py b/donna/lib/__init__.py index 89dc1ba..d402be3 100644 --- a/donna/lib/__init__.py +++ b/donna/lib/__init__.py @@ -1,7 +1,7 @@ """Shared instances for standard library kind definitions.""" from donna.primitives.artifacts import Workflow -from donna.primitives.directives import GoTo, TaskVariable +from donna.primitives.directives import GoTo, Path, TaskVariable from donna.primitives.sections import FinishWorkflow, Output, RequestAction, RunScript, Text workflow = Workflow() @@ -12,4 +12,5 @@ run_script = RunScript() goto = GoTo(analyze_id="goto") +path = Path(analyze_id="path") task_variable = TaskVariable(analyze_id="task_variable") diff --git a/donna/lib/tests/test_init.py b/donna/lib/tests/test_init.py index 8c50a3a..b79075e 100644 --- a/donna/lib/tests/test_init.py +++ b/donna/lib/tests/test_init.py @@ -1,6 +1,6 @@ import donna.lib as lib from donna.primitives.artifacts import Workflow -from donna.primitives.directives import GoTo, TaskVariable +from donna.primitives.directives import GoTo, Path, TaskVariable from donna.primitives.sections import FinishWorkflow, Output, RequestAction, RunScript, Text @@ -16,5 +16,7 @@ def test_section_and_artifact_primitives_are_initialized(self) -> None: def test_directive_primitives_are_initialized_with_analyze_ids(self) -> None: assert isinstance(lib.goto, GoTo) assert lib.goto.analyze_id == "goto" + assert isinstance(lib.path, Path) + assert lib.path.analyze_id == "path" assert isinstance(lib.task_variable, TaskVariable) assert lib.task_variable.analyze_id == "task_variable" diff --git a/donna/primitives/directives/__init__.py b/donna/primitives/directives/__init__.py index 3dc2478..4be6d17 100644 --- a/donna/primitives/directives/__init__.py +++ b/donna/primitives/directives/__init__.py @@ -1,4 +1,5 @@ from donna.primitives.directives.goto import GoTo +from donna.primitives.directives.path import Path from donna.primitives.directives.task_variable import TaskVariable -__all__ = ("GoTo", "TaskVariable") +__all__ = ("GoTo", "Path", "TaskVariable") diff --git a/donna/primitives/directives/path.py b/donna/primitives/directives/path.py new file mode 100644 index 0000000..25922cf --- /dev/null +++ b/donna/primitives/directives/path.py @@ -0,0 +1,106 @@ +import enum + +from donna.core import errors as core_errors +from donna.core.errors import ErrorsList +from donna.core.result import Err, Ok, Result +from donna.domain.artifact_ids import ArtifactId +from donna.machine.templates import Directive, PreparedDirectiveResult +from donna.machine.templates_context import DirectiveContext +from donna.workspaces import config as workspace_config +from donna.workspaces.paths import normalize_project_path, resolve_project_path + + +class PathRenderMode(enum.StrEnum): + project = "project" + absolute = "absolute" + + +class EnvironmentError(core_errors.EnvironmentError): + cell_kind: str = "directive_error" + + +class PathInvalidArguments(EnvironmentError): + code: str = "donna.directives.path.invalid_arguments" + message: str = ( + "Path directive requires exactly one path argument and an optional mode keyword " + "(got {error.provided_count} positional arguments)." + ) + ways_to_fix: list[str] = ['Use `donna.lib.path("")` or `donna.lib.path("", mode="absolute")`.'] + provided_count: int + + +class PathInvalidKeywordArguments(EnvironmentError): + code: str = "donna.directives.path.invalid_keyword_arguments" + message: str = "Path directive received unsupported keyword arguments: {error.keywords}." + ways_to_fix: list[str] = ['Use only the optional `mode` keyword: `mode="project"` or `mode="absolute"`.'] + keywords: list[str] + + +class PathInvalidMode(EnvironmentError): + code: str = "donna.directives.path.invalid_mode" + message: str = "Path directive mode must be `project` or `absolute`, got `{error.mode}`." + ways_to_fix: list[str] = ['Use `mode="project"` or `mode="absolute"`.'] + mode: str + + +class PathArtifactContextMissing(EnvironmentError): + code: str = "donna.directives.path.artifact_context_missing" + message: str = "Path directive requires the current artifact id to resolve workflow-relative paths." + ways_to_fix: list[str] = ["Render the directive inside a workflow artifact."] + + +class PathNotProjectPath(EnvironmentError): + code: str = "donna.directives.path.not_project_path" + message: str = "Path directive could not normalize `{error.path}` to a project path inside the project root." + ways_to_fix: list[str] = ["Use a root-anchored path or a path that resolves inside the Donna project root."] + path: str + + +class Path(Directive): + def _prepare_arguments( + self, + context: DirectiveContext, + *argv: object, + **kwargs: object, + ) -> PreparedDirectiveResult: + if argv is None or len(argv) != 1: + return Err([PathInvalidArguments(provided_count=0 if argv is None else len(argv))]) + + extra_keywords = sorted(set(kwargs) - {"mode"}) + if extra_keywords: + return Err([PathInvalidKeywordArguments(keywords=extra_keywords)]) + + try: + mode = PathRenderMode(str(kwargs.get("mode", PathRenderMode.project))) + except ValueError: + return Err([PathInvalidMode(mode=str(kwargs.get("mode")))]) + + raw_artifact_id = context.get("artifact_id") + if not isinstance(raw_artifact_id, str): + return Err([PathArtifactContextMissing()]) + + return Ok((str(argv[0]), mode, ArtifactId(raw_artifact_id))) + + def render_view(self, context: DirectiveContext, *argv: object) -> Result[object, ErrorsList]: + raw_path = str(argv[0]) + mode: PathRenderMode = argv[1] # type: ignore[assignment] + artifact_id: ArtifactId = argv[2] # type: ignore[assignment] + project_root = workspace_config.project_dir() + + normalized = normalize_project_path(raw_path, project_root, relative_to=artifact_id) + + if normalized is None: + return Err([PathNotProjectPath(path=raw_path)]) + + if mode == PathRenderMode.project: + return Ok(normalized) + + absolute = resolve_project_path(normalized, project_root) + + if absolute is None: + return Err([PathNotProjectPath(path=raw_path)]) + + return Ok(str(absolute)) + + def render_analyze(self, context: DirectiveContext, *argv: object) -> Result[object, ErrorsList]: + return self.render_view(context, *argv) diff --git a/donna/primitives/directives/tests/test_path.py b/donna/primitives/directives/tests/test_path.py new file mode 100644 index 0000000..1119f58 --- /dev/null +++ b/donna/primitives/directives/tests/test_path.py @@ -0,0 +1,153 @@ +import pathlib + +from pytest_mock import MockerFixture + +from donna.domain.artifact_ids import ArtifactId +from donna.domain.paths import ProjectRootPath +from donna.machine.tests import make as machine_make +from donna.primitives.directives import path +from donna.primitives.directives.path import ( + Path, + PathArtifactContextMissing, + PathInvalidArguments, + PathInvalidKeywordArguments, + PathInvalidMode, + PathNotProjectPath, + PathRenderMode, +) +from donna.primitives.tests import make + + +class TestPath: + def test_prepare_arguments__defaults_to_project_mode(self) -> None: + result = Path(analyze_id="path")._prepare_arguments( + make.template_context(artifact_id=machine_make.ARTIFACT_ID), + "../specs/design.md", + ) + + assert result.is_ok() + assert result.unwrap() == ( + "../specs/design.md", + PathRenderMode.project, + ArtifactId("@/workflows/test.donna.md"), + ) + + def test_prepare_arguments__accepts_absolute_mode(self) -> None: + result = Path(analyze_id="path")._prepare_arguments( + make.template_context(artifact_id=machine_make.ARTIFACT_ID), + "@/README.md", + mode="absolute", + ) + + assert result.is_ok() + assert result.unwrap()[1] == PathRenderMode.absolute + + def test_prepare_arguments__requires_one_path_argument(self) -> None: + result = Path(analyze_id="path")._prepare_arguments( + make.template_context(artifact_id=machine_make.ARTIFACT_ID) + ) + + assert result.is_err() + error = result.unwrap_err()[0] + assert isinstance(error, PathInvalidArguments) + assert error.provided_count == 0 + + def test_prepare_arguments__rejects_unknown_keyword_arguments(self) -> None: + result = Path(analyze_id="path")._prepare_arguments( + make.template_context(artifact_id=machine_make.ARTIFACT_ID), + "@/README.md", + unknown=True, + ) + + assert result.is_err() + error = result.unwrap_err()[0] + assert isinstance(error, PathInvalidKeywordArguments) + assert error.keywords == ["unknown"] + + def test_prepare_arguments__rejects_invalid_mode(self) -> None: + result = Path(analyze_id="path")._prepare_arguments( + make.template_context(artifact_id=machine_make.ARTIFACT_ID), + "@/README.md", + mode="host", + ) + + assert result.is_err() + error = result.unwrap_err()[0] + assert isinstance(error, PathInvalidMode) + assert error.mode == "host" + + def test_prepare_arguments__requires_artifact_context(self) -> None: + result = Path(analyze_id="path")._prepare_arguments(make.template_context(), "@/README.md") + + assert result.is_err() + assert isinstance(result.unwrap_err()[0], PathArtifactContextMissing) + + def test_render_view__renders_project_root_anchored_path( + self, mocker: MockerFixture, tmp_path: pathlib.Path + ) -> None: + mocker.patch.object(path.workspace_config, "project_dir", return_value=ProjectRootPath(tmp_path)) + + result = Path(analyze_id="path").render_view( + make.template_context(), + "../specs/design.md", + PathRenderMode.project, + ArtifactId("@/workflows/rfc/design.donna.md"), + ) + + assert result.is_ok() + assert result.unwrap() == "@/workflows/specs/design.md" + + def test_render_view__renders_absolute_path(self, mocker: MockerFixture, tmp_path: pathlib.Path) -> None: + mocker.patch.object(path.workspace_config, "project_dir", return_value=ProjectRootPath(tmp_path)) + + result = Path(analyze_id="path").render_view( + make.template_context(), + "@/README.md", + PathRenderMode.absolute, + ArtifactId("@/workflows/rfc/design.donna.md"), + ) + + assert result.is_ok() + assert result.unwrap() == str(tmp_path / "README.md") + + def test_render_view__accepts_absolute_input(self, mocker: MockerFixture, tmp_path: pathlib.Path) -> None: + mocker.patch.object(path.workspace_config, "project_dir", return_value=ProjectRootPath(tmp_path)) + absolute = tmp_path / "specs" / "design.md" + + result = Path(analyze_id="path").render_view( + make.template_context(), + str(absolute), + PathRenderMode.project, + ArtifactId("@/workflows/rfc/design.donna.md"), + ) + + assert result.is_ok() + assert result.unwrap() == "@/specs/design.md" + + def test_render_view__rejects_paths_outside_project(self, mocker: MockerFixture, tmp_path: pathlib.Path) -> None: + mocker.patch.object(path.workspace_config, "project_dir", return_value=ProjectRootPath(tmp_path)) + + result = Path(analyze_id="path").render_view( + make.template_context(), + "../../outside.md", + PathRenderMode.project, + ArtifactId("@/workflow.donna.md"), + ) + + assert result.is_err() + error = result.unwrap_err()[0] + assert isinstance(error, PathNotProjectPath) + assert error.path == "../../outside.md" + + def test_render_analyze__renders_regular_path(self, mocker: MockerFixture, tmp_path: pathlib.Path) -> None: + mocker.patch.object(path.workspace_config, "project_dir", return_value=ProjectRootPath(tmp_path)) + + result = Path(analyze_id="path").render_analyze( + make.template_context(), + "@/specs/design.md", + PathRenderMode.project, + ArtifactId("@/workflows/rfc/design.donna.md"), + ) + + assert result.is_ok() + assert result.unwrap() == "@/specs/design.md" diff --git a/donna/skills/fixtures/workflows.md b/donna/skills/fixtures/workflows.md index 6ff90aa..26d07bd 100644 --- a/donna/skills/fixtures/workflows.md +++ b/donna/skills/fixtures/workflows.md @@ -470,6 +470,22 @@ Use the same key that the earlier operation saved: save_stdout_to = "test_stdout" ``` +### `path` + +`{{ donna.lib.path("") }}` renders a normalized project-root-anchored path. + +Use it when workflow instructions reference project files. The path argument may be root-anchored, absolute, or relative to the workflow file that contains the directive. + +Assume the project root is `/project` and the current workflow file is `@/workflows/rfc/design.donna.md`. + +- `{{ donna.lib.path("specs/design.md") }}` renders `@/workflows/rfc/specs/design.md`. +- `{{ donna.lib.path("../shared.md") }}` renders `@/workflows/shared.md`. +- `{{ donna.lib.path("@/README.md") }}` renders `@/README.md`. +- `{{ donna.lib.path("/project/specs/behavior/file_paths.md") }}` renders `@/specs/behavior/file_paths.md`. +- `{{ donna.lib.path("specs/design.md", mode="absolute") }}` renders `/project/workflows/rfc/specs/design.md`. + +The default mode is `project`, which renders root-anchored paths. Use `mode="absolute"` only when the receiving tool needs a filesystem path. + ## Creating Workflows Start with the workflow's control-flow shape, then fill operation instructions. diff --git a/donna/workspaces/artifacts.py b/donna/workspaces/artifacts.py index 21bd215..dded5f8 100644 --- a/donna/workspaces/artifacts.py +++ b/donna/workspaces/artifacts.py @@ -159,16 +159,6 @@ def fetch_raw_artifact(artifact_id: ArtifactId) -> Result[FilesystemRawArtifact, if artifact_path is None: return Err([world_errors.ArtifactNotFound(artifact_id=artifact_id)]) - if not has_donna_artifact_extension(artifact_path): - return Err( - [ - world_errors.UnsupportedArtifactExtension( - artifact_id=artifact_id, - extension="".join(artifact_path.suffixes).lower() or artifact_path.suffix.lower(), - ) - ] - ) - return Ok( FilesystemRawArtifact( path=artifact_path, diff --git a/donna/workspaces/errors.py b/donna/workspaces/errors.py index f3180fc..0eaab40 100644 --- a/donna/workspaces/errors.py +++ b/donna/workspaces/errors.py @@ -1,6 +1,5 @@ from donna.core import errors as core_errors from donna.domain.artifact_ids import ArtifactId -from donna.domain.constants import DONNA_ARTIFACT_EXTENSION from donna.domain.paths import ProjectConfigPath @@ -126,15 +125,6 @@ class ArtifactMultipleFiles(ArtifactError): ] -class UnsupportedArtifactExtension(ArtifactError): - code: str = "donna.workspaces.unsupported_artifact_extension" - message: str = "Unsupported artifact extension `{error.extension}` for `{error.artifact_id}`" - ways_to_fix: list[str] = [ - f"Use the Donna artifact extension: `*{DONNA_ARTIFACT_EXTENSION}`.", - ] - extension: str - - class MarkdownError(WorkspaceError): cell_kind: str = "markdown_error" artifact_id: ArtifactId | None = None diff --git a/donna/workspaces/paths.py b/donna/workspaces/paths.py index cc47a82..9a87c54 100644 --- a/donna/workspaces/paths.py +++ b/donna/workspaces/paths.py @@ -12,7 +12,15 @@ ) from donna.domain.constants import ARTIFACT_ID_PREFIX from donna.domain.ids import SectionId -from donna.domain.paths import PathInput, ProjectPathId, ProjectRootPath, ResolvedProjectPath, UntrustedPath +from donna.domain.paths import ( + PathInput, + ProjectPathId, + ProjectPathRaw, + ProjectRootPath, + ResolvedProjectPath, + UntrustedPath, + validate_project_path_id, +) PROJECT_ROOT_PREFIX = ARTIFACT_ID_PREFIX @@ -34,7 +42,7 @@ def _append_normalized_part(parts: list[str], part: str) -> bool: return True -def _normalize_parts(raw: str, *, initial_parts: tuple[str, ...] = ()) -> ProjectPathId | None: +def _normalize_parts(raw: ProjectPathRaw, *, initial_parts: tuple[str, ...] = ()) -> ProjectPathId | None: if not raw: return None @@ -48,7 +56,7 @@ def _normalize_parts(raw: str, *, initial_parts: tuple[str, ...] = ()) -> Projec return None normalized = ProjectPathId(PROJECT_ROOT_PREFIX + "/".join(parts)) - if not validate_artifact_id(normalized): + if not validate_project_path_id(normalized): return None return normalized @@ -62,7 +70,7 @@ def _normalize_root_anchored(value: str) -> ProjectPathId | None: if not value.startswith(PROJECT_ROOT_PREFIX): return None - return _normalize_parts(value.removeprefix(PROJECT_ROOT_PREFIX)) + return _normalize_parts(ProjectPathRaw(value.removeprefix(PROJECT_ROOT_PREFIX))) def _resolve_inside_project(path: UntrustedPath, root: ProjectRootPath) -> ResolvedProjectPath | None: @@ -77,7 +85,7 @@ def _resolve_inside_project(path: UntrustedPath, root: ProjectRootPath) -> Resol def _canonical_from_resolved(resolved: ResolvedProjectPath, root: ProjectRootPath) -> ProjectPathId | None: normalized = ProjectPathId(PROJECT_ROOT_PREFIX + pathlib.Path(resolved).relative_to(pathlib.Path(root)).as_posix()) - if not validate_artifact_id(normalized): + if not validate_project_path_id(normalized): return None return normalized @@ -139,10 +147,10 @@ def _normalize_from_artifact(value: str, relative_to: ArtifactId) -> ProjectPath if value.startswith(PROJECT_ROOT_PREFIX): return _normalize_root_anchored(value) - return _normalize_parts(value, initial_parts=artifact_path_parts(relative_to)[:-1]) + return _normalize_parts(ProjectPathRaw(value), initial_parts=artifact_path_parts(relative_to)[:-1]) -def normalize_artifact_path( +def normalize_project_path( value: str, root: PathInput, *, @@ -152,10 +160,24 @@ def normalize_artifact_path( if not isinstance(value, str) or not value: return None - if relative_to is not None: - return _normalize_from_artifact(value, relative_to) + if relative_to is None: + return normalize_path(value, root, cwd=cwd) - return normalize_path(value, root, cwd=cwd) + path = pathlib.Path(value).expanduser() + if value.startswith(PROJECT_ROOT_PREFIX) or path.is_absolute(): + return normalize_path(value, root, cwd=cwd) + + return _normalize_from_artifact(value, relative_to) + + +def normalize_artifact_path( + value: str, + root: PathInput, + *, + cwd: PathInput | None = None, + relative_to: ArtifactId | None = None, +) -> ProjectPathId | None: + return normalize_project_path(value, root, cwd=cwd, relative_to=relative_to) def normalize_artifact_id( @@ -169,6 +191,9 @@ def normalize_artifact_id( if normalized is None: return None + if not validate_artifact_id(normalized): + return None + return ArtifactId(normalized) diff --git a/donna/workspaces/tests/test_artifacts.py b/donna/workspaces/tests/test_artifacts.py index 82e8d7c..5bc8150 100644 --- a/donna/workspaces/tests/test_artifacts.py +++ b/donna/workspaces/tests/test_artifacts.py @@ -34,7 +34,7 @@ def test_returns_artifact_id_for_valid_parts(self) -> None: assert artifacts._artifact_id_from_parts(["workflows", "test.donna.md"]) == make.ARTIFACT_ID def test_returns_none_for_invalid_parts(self) -> None: - assert artifacts._artifact_id_from_parts(["invalid name.donna.md"]) is None + assert artifacts._artifact_id_from_parts(["..", "test.donna.md"]) is None class TestWorkflowDirParts: @@ -81,19 +81,16 @@ def test_returns_artifact_id_for_regular_donna_file(self, tmp_path: pathlib.Path artifacts._artifact_id_from_filesystem_entry(ResolvedProjectPath(path), ["workflows"]) == make.ARTIFACT_ID ) - def test_returns_none_for_dirs_non_artifacts_and_invalid_names(self, tmp_path: pathlib.Path) -> None: + def test_returns_none_for_dirs_and_non_artifacts(self, tmp_path: pathlib.Path) -> None: directory = tmp_path / "directory" directory.mkdir() ordinary_markdown = tmp_path / "ordinary.md" ordinary_markdown.write_text("", encoding="utf-8") - invalid_name = tmp_path / "invalid name.donna.md" - invalid_name.write_text("", encoding="utf-8") assert artifacts._artifact_id_from_filesystem_entry(ResolvedProjectPath(directory), ["workflows"]) is None assert ( artifacts._artifact_id_from_filesystem_entry(ResolvedProjectPath(ordinary_markdown), ["workflows"]) is None ) - assert artifacts._artifact_id_from_filesystem_entry(ResolvedProjectPath(invalid_name), ["workflows"]) is None class TestWalkWorkflowDir: @@ -118,13 +115,14 @@ def test_walk_filesystem__lists_artifacts_in_workflow_dirs( nested.mkdir(parents=True) (workflows / "b.donna.md").write_text("", encoding="utf-8") (workflows / "ignored.md").write_text("", encoding="utf-8") - (workflows / "invalid name.donna.md").write_text("", encoding="utf-8") + (workflows / "spaced name.donna.md").write_text("", encoding="utf-8") (nested / "a.donna.md").write_text("", encoding="utf-8") mocker.patch("donna.workspaces.config.project_dir", return_value=tmp_path) assert list(artifacts.walk_filesystem([RelativeProjectPath(pathlib.Path("workflows"))])) == [ ArtifactId("@/workflows/b.donna.md"), ArtifactId("@/workflows/nested/a.donna.md"), + ArtifactId("@/workflows/spaced name.donna.md"), ] def test_walk_filesystem__preserves_workflow_dir_order( @@ -269,26 +267,6 @@ def test_fetch_raw_artifact__rejects_artifacts_outside_workflow_dirs( assert result.is_err() assert isinstance(result.unwrap_err()[0], workspace_errors.ArtifactNotFound) - def test_fetch_raw_artifact__rejects_unsupported_artifact_extension( - self, mocker: MockerFixture, tmp_path: pathlib.Path - ) -> None: - artifact_id = ArtifactId("@/workflows/test.md") - path = tmp_path / "workflows" / "test.md" - path.parent.mkdir() - path.write_text("content", encoding="utf-8") - mocker.patch("donna.workspaces.config.project_dir", return_value=tmp_path) - mocker.patch( - "donna.workspaces.config.config", - return_value=Config(workflow_dirs=[RelativeProjectPath(pathlib.Path("workflows"))]), - ) - - result = artifacts.fetch_raw_artifact(artifact_id) - - assert result.is_err() - error = result.unwrap_err()[0] - assert isinstance(error, workspace_errors.UnsupportedArtifactExtension) - assert error.extension == ".md" - class TestFetchArtifactBytes: def test_fetch_artifact_bytes__returns_raw_bytes(self, mocker: MockerFixture) -> None: diff --git a/donna/workspaces/tests/test_errors.py b/donna/workspaces/tests/test_errors.py index 2f80274..dba9d96 100644 --- a/donna/workspaces/tests/test_errors.py +++ b/donna/workspaces/tests/test_errors.py @@ -103,11 +103,6 @@ def error(self) -> workspace_errors.WorkspaceError: return workspace_errors.ArtifactMultipleFiles(artifact_id=make.ARTIFACT_ID) -class TestUnsupportedArtifactExtension(_EnvironmentErrorCase): - def error(self) -> workspace_errors.WorkspaceError: - return workspace_errors.UnsupportedArtifactExtension(artifact_id=make.ARTIFACT_ID, extension=".md") - - class TestMarkdownError: def test_content_intro__describes_source_without_artifact_id(self) -> None: error = workspace_errors.MarkdownArtifactWithoutSections() diff --git a/donna/workspaces/tests/test_paths.py b/donna/workspaces/tests/test_paths.py index 93b4da6..5b0dfbc 100644 --- a/donna/workspaces/tests/test_paths.py +++ b/donna/workspaces/tests/test_paths.py @@ -1,7 +1,7 @@ import pathlib from donna.domain.artifact_ids import ArtifactId -from donna.domain.paths import ProjectRootPath, ResolvedProjectPath, UntrustedPath +from donna.domain.paths import ProjectPathRaw, ProjectRootPath, ResolvedProjectPath, UntrustedPath from donna.workspaces import paths from donna.workspaces.paths import ( normalize_artifact_id, @@ -9,6 +9,7 @@ normalize_artifact_section_id, normalize_existing_path, normalize_path, + normalize_project_path, resolve_project_path, resolve_project_root, ) @@ -35,18 +36,30 @@ def test_parent_part_removes_previous_part(self) -> None: class TestNormalizeParts: def test_returns_canonical_project_path(self) -> None: - assert paths._normalize_parts("workflows/./nested/../test.donna.md") == "@/workflows/test.donna.md" + assert paths._normalize_parts(ProjectPathRaw("workflows/./nested/../test.donna.md")) == ( + "@/workflows/test.donna.md" + ) + assert paths._normalize_parts(ProjectPathRaw("README")) == "@/README" + assert paths._normalize_parts(ProjectPathRaw("workflows")) == "@/workflows" + assert paths._normalize_parts(ProjectPathRaw("src/package/module.py")) == "@/src/package/module.py" + assert paths._normalize_parts(ProjectPathRaw("workflows/project plan.donna.md")) == ( + "@/workflows/project plan.donna.md" + ) + assert paths._normalize_parts(ProjectPathRaw("workflows/Проектный план.donna.md")) == ( + "@/workflows/Проектный план.donna.md" + ) def test_uses_initial_parts_for_relative_artifact_paths(self) -> None: assert ( - paths._normalize_parts("../plan.donna.md", initial_parts=("workflows", "rfc")) + paths._normalize_parts(ProjectPathRaw("../plan.donna.md"), initial_parts=("workflows", "rfc")) == "@/workflows/plan.donna.md" ) - def test_rejects_empty_root_and_invalid_artifact_paths(self) -> None: - assert paths._normalize_parts("") is None - assert paths._normalize_parts(".") is None - assert paths._normalize_parts("invalid name.donna.md") is None + def test_rejects_empty_root_and_malformed_project_paths(self) -> None: + assert paths._normalize_parts(ProjectPathRaw("")) is None + assert paths._normalize_parts(ProjectPathRaw(".")) is None + assert paths._normalize_parts(ProjectPathRaw("..")) is None + assert paths._normalize_parts(ProjectPathRaw("workflows//test.donna.md")) is None class TestResolveProjectRoot: @@ -90,10 +103,28 @@ def test_returns_canonical_path_for_valid_resolved_path(self, tmp_path: pathlib. == "@/workflow.donna.md" ) - def test_rejects_invalid_artifact_path(self, tmp_path: pathlib.Path) -> None: - project_file = tmp_path / "invalid name.donna.md" + def test_returns_canonical_path_for_filesystem_like_path(self, tmp_path: pathlib.Path) -> None: + project_file = tmp_path / "project plan.donna.md" + + assert ( + paths._canonical_from_resolved(ResolvedProjectPath(project_file), ProjectRootPath(tmp_path)) + == "@/project plan.donna.md" + ) - assert paths._canonical_from_resolved(ResolvedProjectPath(project_file), ProjectRootPath(tmp_path)) is None + def test_returns_canonical_path_for_suffixless_path(self, tmp_path: pathlib.Path) -> None: + project_file = tmp_path / "README" + + assert ( + paths._canonical_from_resolved(ResolvedProjectPath(project_file), ProjectRootPath(tmp_path)) == "@/README" + ) + + def test_returns_canonical_path_for_directory_path(self, tmp_path: pathlib.Path) -> None: + project_dir = tmp_path / "workflows" + + assert ( + paths._canonical_from_resolved(ResolvedProjectPath(project_dir), ProjectRootPath(tmp_path)) + == "@/workflows" + ) class TestResolveRootAnchoredPath: @@ -103,8 +134,14 @@ def test_resolves_root_anchored_path_inside_project(self, tmp_path: pathlib.Path assert paths._resolve_root_anchored_path("@/workflow.donna.md", ProjectRootPath(tmp_path)) == project_file + def test_resolves_filesystem_like_root_anchored_path(self, tmp_path: pathlib.Path) -> None: + assert ( + paths._resolve_root_anchored_path("@/project plan.donna.md", ProjectRootPath(tmp_path)) + == tmp_path / "project plan.donna.md" + ) + def test_rejects_invalid_root_anchored_path(self, tmp_path: pathlib.Path) -> None: - assert paths._resolve_root_anchored_path("@/invalid name.donna.md", ProjectRootPath(tmp_path)) is None + assert paths._resolve_root_anchored_path("@/../outside.donna.md", ProjectRootPath(tmp_path)) is None class TestResolveProjectPath: @@ -136,6 +173,8 @@ def test_rejects_absolute_path_when_not_allowed(self, tmp_path: pathlib.Path) -> class TestNormalizePath: def test_normalizes_root_anchored_path(self, tmp_path: pathlib.Path) -> None: assert normalize_path("@/workflows/./nested/../test.donna.md", tmp_path) == "@/workflows/test.donna.md" + assert normalize_path("@/workflows", tmp_path) == "@/workflows" + assert normalize_path("@/README", tmp_path) == "@/README" def test_normalizes_absolute_path_inside_project(self, tmp_path: pathlib.Path) -> None: project_file = tmp_path / "workflows" / "test.donna.md" @@ -190,6 +229,30 @@ def test_rejects_invalid_artifact_path(self, tmp_path: pathlib.Path) -> None: assert normalize_artifact_path(None, tmp_path) is None # type: ignore[arg-type] +class TestNormalizeProjectPath: + def test_normalizes_relative_to_artifact_file(self, tmp_path: pathlib.Path) -> None: + relative_to = ArtifactId("@/workflows/rfc/do.donna.md") + + assert normalize_project_path("specs/design.md", tmp_path, relative_to=relative_to) == ( + "@/workflows/rfc/specs/design.md" + ) + + def test_normalizes_absolute_path_when_artifact_base_is_present(self, tmp_path: pathlib.Path) -> None: + project_file = tmp_path / "specs" / "design.md" + project_file.parent.mkdir() + project_file.write_text("", encoding="utf-8") + relative_to = ArtifactId("@/workflows/rfc/do.donna.md") + + assert normalize_project_path(str(project_file), tmp_path, relative_to=relative_to) == "@/specs/design.md" + + def test_accepts_project_paths_with_non_workflow_extensions(self, tmp_path: pathlib.Path) -> None: + assert normalize_project_path("@/specs/design.md", tmp_path) == "@/specs/design.md" + + def test_accepts_project_paths_without_suffixes(self, tmp_path: pathlib.Path) -> None: + assert normalize_project_path("@/workflows", tmp_path) == "@/workflows" + assert normalize_project_path("@/README", tmp_path) == "@/README" + + class TestNormalizeFromArtifact: def test_normalizes_root_anchored_and_artifact_relative_paths(self) -> None: relative_to = ArtifactId("@/workflows/rfc/do.donna.md") @@ -210,6 +273,7 @@ def test_returns_artifact_id_for_relative_path_from_cwd(self, tmp_path: pathlib. def test_rejects_invalid_artifact_id_path(self, tmp_path: pathlib.Path) -> None: assert normalize_artifact_id("@/workflow", tmp_path) is None + assert normalize_artifact_id("@/workflow.md", tmp_path) is None class TestNormalizeArtifactSectionId: diff --git a/specs/architecture/entities.md b/specs/architecture/entities.md index e1d8a64..2aa09a3 100644 --- a/specs/architecture/entities.md +++ b/specs/architecture/entities.md @@ -145,6 +145,18 @@ Shared semantic primitive types SHOULD belong to the domain module. Module-specific semantic primitive types SHOULD belong to the owning module. +## Static typing suppressions + +When a static type checker cannot prove a semantic primitive type relationship that is already guaranteed by validation or construction at the same boundary, code SHOULD use a local `# type: ignore[...]` suppression instead of `typing.cast(...)` or a pure runtime no-op conversion such as `str(...)`. + +Type suppressions MUST be as narrow as practical. + +Type suppressions SHOULD include the static checker error code when the checker provides one. + +Type suppressions MUST NOT be used to bypass missing validation, unsafe external input conversion, or a real mismatch between runtime behavior and declared types. + +Runtime constructors and validation functions SHOULD remain the preferred way to convert untrusted primitive input into semantic primitive types. + ## Entity ownership Shared entity infrastructure MUST belong to the core module. diff --git a/specs/behavior/file_paths.md b/specs/behavior/file_paths.md index 5562efc..82a11bc 100644 --- a/specs/behavior/file_paths.md +++ b/specs/behavior/file_paths.md @@ -18,7 +18,7 @@ The following topics are out of scope: ## Dictionary - `project root` — the root directory of the active Donna project. -- `project path` — a file-like path identifier that addresses a non-root location inside the project root and can be represented as a canonical root-anchored id. +- `project path` — a path identifier that addresses a non-root filesystem location inside the project root and can be represented as a canonical root-anchored id. - `artifact id` — a canonical project-root-anchored identifier for a Donna artifact file. - `artifact section id` — an artifact id plus a section id separated by `:`. - `root-anchored path` — a project path that starts with `@/` and is resolved from the project root. @@ -40,14 +40,14 @@ A project path MUST NOT identify a location outside the project root after path A project path MUST NOT use an absolute host filesystem path as its canonical representation. -A canonical project path MUST satisfy Donna artifact id path syntax, including a suffixed final path segment. +A canonical project path MUST satisfy Donna root-anchored path syntax. ## Root-anchored syntax The canonical syntax for a project path MUST be: ```text -@/path/inside/project.ext +@/path/inside/project ``` The `@/` marker MUST represent the project root. @@ -63,12 +63,24 @@ The canonical representation MUST NOT contain: - `..` path segments. - a trailing `/`. +Project path segments are normalized filesystem path components and MAY contain any characters accepted by the local filesystem except path separators and other characters that cannot appear in a single path component. + Examples of valid canonical paths: ```text @/README.md +@/README +@/LICENSE +@/Makefile +@/workflows @/workflows/polish.donna.md +@/workflows/archive +@/src/donna @/.session/donna/plans/implement-feature.donna.md +@/workflows/Заметки проекта.donna.md +@/workflows/Проектный план +@/workflows/project plan.donna.md +@/данные/отчёт.txt ``` Examples of invalid canonical paths: @@ -96,6 +108,8 @@ A field that declares existing-file semantics MUST reject or skip canonical path A field that declares reference semantics MAY accept canonical paths that do not exist yet. +A project path MAY refer to a file, a directory, or a not-yet-existing location. File existence, directory existence, file suffix requirements, and concrete file extension requirements MUST be enforced only by contexts that need those constraints. + ## Path normalization Path normalization MUST produce a canonical root-anchored path. @@ -115,23 +129,14 @@ Implementations MUST reject inputs that cannot be normalized to a project path i ## Artifact ids -An artifact id MUST be a canonical root-anchored project path. - -Artifact ids accepted by workflow commands MUST identify files with the Donna artifact extension: +An artifact id MUST be a canonical root-anchored project path that identifies a file with the Donna artifact extension: ```text .donna.md ``` Artifact id path segments MUST contain only: - -- ASCII letters. -- ASCII digits. -- `.`. -- `_`. -- `-`. - -Each artifact id path segment MUST contain at least one character that is not `.` or `-`. +- normalized filesystem path components accepted by project path syntax. The last path segment MUST have a file suffix. @@ -143,6 +148,7 @@ Examples: @/workflows/polish.donna.md @/workflows/rfc/do.donna.md @/.session/donna/plans/feature.donna.md +@/workflows/Проектный план.donna.md ``` ## Artifact section ids @@ -159,6 +165,8 @@ The artifact part MUST be a valid artifact id. The section id part MUST be a valid Donna section id. +Section id syntax MUST remain independent from artifact id path segment syntax. + Section ids MUST contain only: - ASCII letters. @@ -217,6 +225,8 @@ CLI artifact path arguments MUST resolve relative paths from the command's curre Artifact-local relative paths MAY be resolved relative to the directory that contains the source artifact. +Template path directives MUST resolve relative paths from the directory that contains the workflow artifact being rendered. + When the base path is a file, the relative path MUST be resolved against the directory that contains the base file. When the base path is a directory, the relative path MUST be resolved against that directory. @@ -251,6 +261,26 @@ MUST fail if it escapes the project root. New CLI examples and workflow instructions SHOULD prefer root-anchored paths unless relative addressing is central to the example. +## Template path directive + +The `donna.lib.path("", mode="project"|"absolute")` directive MUST normalize local project paths in workflow text. + +The directive MUST accept: + +- root-anchored paths. +- relative paths resolved from the workflow file that contains the directive. +- absolute filesystem paths that resolve inside the Donna project root. + +The default mode MUST be `project`. + +When `mode = "project"`, the directive MUST render the normalized canonical root-anchored project path. + +When `mode = "absolute"`, the directive MUST render the corresponding absolute filesystem path. + +The directive MUST reject paths that cannot be normalized to a non-root location inside the Donna project root. + +Workflow instructions SHOULD use this directive when they need to reference project files from rendered agent-facing text. + ## CLI path inputs CLI input parameters that accept artifact paths MUST accept: diff --git a/specs/behavior/skill_fixtures.md b/specs/behavior/skill_fixtures.md index 3db53ca..50ea35f 100644 --- a/specs/behavior/skill_fixtures.md +++ b/specs/behavior/skill_fixtures.md @@ -155,6 +155,8 @@ It MUST describe `donna.lib.goto` for action request transitions. It MUST describe `donna.lib.task_variable` for rendering task variables. +It MUST describe `donna.lib.path` for rendering normalized project-root-anchored or absolute project paths. + It MUST include at least one complete minimal workflow example. It MUST describe validation expectations for workflows. diff --git a/workflows/rfc/design.donna.md b/workflows/rfc/design.donna.md index 55ef999..911c4f4 100644 --- a/workflows/rfc/design.donna.md +++ b/workflows/rfc/design.donna.md @@ -1,6 +1,6 @@ # Create a Design document -This workflow creates a Design document artifact based on an RFC and aligned with `workflows/rfc/specs/design.md`. +This workflow creates a Design document artifact based on an RFC and aligned with `{{ donna.lib.path("specs/design.md") }}`. ## Start Work @@ -10,7 +10,7 @@ kind = "donna.lib.request_action" fsm_mode = "start" ``` -1. Read the `workflows/rfc/specs/design.md` file if you haven't done it yet. +1. Read the `{{ donna.lib.path("specs/design.md") }}` file if you haven't done it yet. 2. Read the workflow instructions by running `donna skill workflows` if you haven't done it yet. 3. `{{ donna.lib.goto("ensure_rfc_artifact_exists") }}` @@ -72,7 +72,7 @@ id = "initial_fill" kind = "donna.lib.request_action" ``` -1. Read the `workflows/rfc/specs/design.md` file if you haven't done it yet. +1. Read the `{{ donna.lib.path("specs/design.md") }}` file if you haven't done it yet. 2. Read the RFC artifact selected in the previous step if you haven't done it yet. 3. Analyze the project if needed to understand the requested change context. 4. Fill in all sections of the Design draft document. @@ -86,7 +86,7 @@ id = "review_design_format" kind = "donna.lib.request_action" ``` -1. List mismatches between the Design artifact and the specification in the `workflows/rfc/specs/design.md` file. +1. List mismatches between the Design artifact and the specification in the `{{ donna.lib.path("specs/design.md") }}` file. 2. For each mismatch, make necessary edits to the Design draft document to ensure compliance. 3. `{{ donna.lib.goto("review_design_content") }}` diff --git a/workflows/rfc/request.donna.md b/workflows/rfc/request.donna.md index 8c1caba..603168a 100644 --- a/workflows/rfc/request.donna.md +++ b/workflows/rfc/request.donna.md @@ -11,7 +11,7 @@ kind = "donna.lib.request_action" fsm_mode = "start" ``` -1. Read the `workflows/rfc/specs/request_for_change.md` file if you haven't done it yet. +1. Read the `{{ donna.lib.path("specs/request_for_change.md") }}` file if you haven't done it yet. 2. Read the workflow instructions by running `donna skill workflows` if you haven't done it yet. 3. `{{ donna.lib.goto("ensure_work_description_exists") }}` @@ -77,7 +77,7 @@ id = "initial_fill" kind = "donna.lib.request_action" ``` -1. Read the `workflows/rfc/specs/request_for_change.md` file if you haven't done it yet. +1. Read the `{{ donna.lib.path("specs/request_for_change.md") }}` file if you haven't done it yet. 2. Analyze the project if needed to understand the context of the requested change. 3. Based on the problem description you have, fill in all sections of the RFC draft document. 4. `{{ donna.lib.goto("review_rfc_format") }}` @@ -89,7 +89,7 @@ id = "review_rfc_format" kind = "donna.lib.request_action" ``` -1. List mismatches between the RFC artifact and the specification in the `workflows/rfc/specs/request_for_change.md` file. +1. List mismatches between the RFC artifact and the specification in the `{{ donna.lib.path("specs/request_for_change.md") }}` file. 2. For each mismatch, make necessary edits to the RFC draft document to ensure compliance with the RFC specification. 3. `{{ donna.lib.goto("review_rfc_content") }}`