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
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions changes/next_release.md
Original file line number Diff line number Diff line change
@@ -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.
25 changes: 9 additions & 16 deletions donna/cli/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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


Expand All @@ -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


Expand Down
35 changes: 8 additions & 27 deletions donna/domain/artifact_ids.py
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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:
Expand All @@ -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):
Expand Down
10 changes: 5 additions & 5 deletions donna/domain/ids.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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)
26 changes: 26 additions & 0 deletions donna/domain/paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 6 additions & 2 deletions donna/domain/tests/test_artifact_ids.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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,
],
Expand Down
6 changes: 3 additions & 3 deletions donna/domain/tests/test_ids.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
47 changes: 47 additions & 0 deletions donna/domain/tests/test_paths.py
Original file line number Diff line number Diff line change
@@ -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)
3 changes: 2 additions & 1 deletion donna/lib/__init__.py
Original file line number Diff line number Diff line change
@@ -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()
Expand All @@ -12,4 +12,5 @@
run_script = RunScript()

goto = GoTo(analyze_id="goto")
path = Path(analyze_id="path")
task_variable = TaskVariable(analyze_id="task_variable")
4 changes: 3 additions & 1 deletion donna/lib/tests/test_init.py
Original file line number Diff line number Diff line change
@@ -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


Expand All @@ -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"
3 changes: 2 additions & 1 deletion donna/primitives/directives/__init__.py
Original file line number Diff line number Diff line change
@@ -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")
Loading