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
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,10 +115,24 @@ The `zest-dev` CLI manages spec files. Use it to inspect and update specs outsid
| `zest-dev unset-active` | Unset active change spec |
| `zest-dev update <spec-id\|active> <status>` | Update spec status |
| `zest-dev create-branch` | Create a git branch from the active change spec |
| `zest-dev dump <spec-id\|active> [--dry-run]` | Archive a spec as an issue representation or GitHub issue |
| `zest-dev dump <spec-id\|path\|active> [--dry-run]` | Archive a directory Spec or standalone dated Markdown record as an issue representation or GitHub issue |
| `zest-dev load [issue] [--from-file <path>]` | Reconstruct a spec from an issue representation or GitHub issue |
| `zest-dev ralph` | Convert active Spec Progress items into Ralph tasks |

### Issue Spec Representation Compatibility

Issue Spec Representation evolves without making existing archives unreadable:

| Protocol | Represented source | `dump` behavior | `load` compatibility | Restored shape |
|----------|--------------------|-----------------|----------------------|----------------|
| V1 | Directory with `spec.md` | No longer emitted | Supported | `specs/change/<spec-id>/` |
| V2 | Directory with one or more Markdown files; `spec.md` is optional | Emitted for directory Specs | Supported | `specs/change/<spec-id>/` |
| V3 | Standalone `YYYYMMDD-slug.md` record | Emitted for standalone files | Supported | `specs/change/<spec-id>.md` |

For directory Specs, `dump` accepts the existing Spec ID, directory/Main Spec path, or `active`. For standalone files, it accepts the direct path, filename, or an unambiguous ID without `.md`. If both `specs/change/<id>/` and `specs/change/<id>.md` exist, the bare ID is ambiguous and fails; pass an explicit path to select one. `load` validates the protocol and refuses to overwrite either the target shape or a conflicting directory/file with the same logical ID.

See [Issue Spec Representation](docs/issue-spec-representation.md) for the body/comment protocol and validation rules.

### Status Transitions

Valid status values: `new`, `designed`, `planned`, `implemented`
Expand Down
44 changes: 39 additions & 5 deletions docs/issue-spec-representation.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ This document defines the forge-neutral issue representation used by `zest-dev d

## Purpose

An Issue Spec Representation stores one complete Markdown file snapshot of a Zest Dev Spec directory in one forge issue. It is a snapshot format, not a synchronization protocol.
An Issue Spec Representation stores either one complete Markdown snapshot of a Zest Dev Spec directory or one standalone dated Markdown record in one forge issue. It is a snapshot format, not a synchronization protocol.

The representation is designed for GitHub and Forgejo issue primitives:
- issue title
Expand All @@ -16,7 +16,7 @@ Load correctness depends only on protocol headers and Markdown content in the is

## Versions

`dump` writes protocol version `2`. `load` accepts versions `1` and `2` so existing archives remain loadable.
`dump` writes protocol version `2` for Spec directories and version `3` for standalone Markdown files. `load` accepts versions `1`, `2`, and `3` so existing archives remain loadable. V1 and V2 always reconstruct directories; V3 always reconstructs a standalone file.

Every protocol body/comment starts with a leading HTML comment header. A V2 issue body for a normal Spec begins like this:

Expand Down Expand Up @@ -99,6 +99,31 @@ Comment order is not meaningful. Comments without a leading protocol header are

Before writing any files, `load` verifies that the body payload and protocol comments match the manifest exactly. Missing, duplicate, or unlisted represented paths fail instead of producing a partial directory.

## V3 Standalone File

V3 represents exactly one dated Markdown file that is a direct child of `specs/change`:

```markdown
<!--
zest-dev-issue-spec: 3
kind: standalone-file
spec-id: 20260101-legacy-record
filename: 20260101-legacy-record.md
-->
# Historical change record
```

The V3 body header has exactly four fields:

- `zest-dev-issue-spec` must be `3`.
- `kind` must be `standalone-file`.
- `spec-id` must be a valid dated Spec ID without `.md`.
- `filename` must be exactly `<spec-id>.md`.

The complete file content starts after the header separator and may be empty. V3 has no protocol file comments because its one file is wholly represented in the issue body. Ordinary non-protocol issue comments are ignored during load.

`dump` accepts the standalone path, its filename, or its ID without `.md`. A bare ID is accepted only when exactly one of `specs/change/<id>/` and `specs/change/<id>.md` exists. When both exist, the ID is ambiguous and fails; an explicit path selects the intended source.

## V1 Load Compatibility

V1 archives do not contain a directory manifest. Their issue body header has `path: spec.md`, and the body payload is required to contain the Main Spec File:
Expand Down Expand Up @@ -165,15 +190,15 @@ In V2, `body-path` must be one of the manifested paths. It describes storage loc

## Spec Identity And Local Write

The loaded Spec identity comes from the protocol header `spec-id`, not from `spec.md` frontmatter, title, labels, issue number, or URL.
The loaded Spec identity comes from the protocol header `spec-id`, not from `spec.md` frontmatter, title, labels, issue number, or URL. For V3, `filename` must agree with that identity.

The `spec-id` must be a valid Spec directory name:

```text
YYYYMMDD-<slug>
```

`load` creates:
V1 and V2 `load` create:

```text
specs/change/<spec-id>/
Expand All @@ -183,6 +208,14 @@ Every protocol comment must use the same version and `spec-id` as the issue body

`load` does not change `specs/change/active`. For a directory without `spec.md`, successful output reports the Spec directory path rather than a nonexistent Main Spec File path.

V3 `load` creates the exact standalone target:

```text
specs/change/<spec-id>.md
```

It fails if that target file or a same-ID target directory already exists. The file is written to a temporary sibling and renamed into place only after validation and a successful write. A loaded standalone historical record is never made active.

## Local Representation Mode

The same body/comment mapping can be represented locally as YAML:
Expand Down Expand Up @@ -222,9 +255,10 @@ The protocol is fail-fast:
- V2 missing, duplicate, or unlisted represented files fail
- unexpected V2 body content without `body-path` fails
- V1 body paths other than `spec.md` or empty `spec.md` content fail
- V3 kinds other than `standalone-file`, mismatched filenames, extra metadata fields, or protocol comments fail
- invalid file paths fail
- invalid UTF-8 Markdown content fails
- existing target Spec directories fail
- existing target shapes or conflicting same-ID directory/file shapes fail
- unsupported forge transports fail
- failed remote issue or comment operations fail

Expand Down
181 changes: 168 additions & 13 deletions e2e/tests/test_issue_dump_load.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import json
import os
import shutil
import stat
from pathlib import Path

Expand Down Expand Up @@ -116,6 +117,145 @@ def test_v2_directory_manifest_round_trips_without_main_file_and_loads_v1(cli):
}


def test_v3_standalone_file_round_trips_by_path_and_unambiguous_id(cli):
spec_id = "20260101-legacy-record"
filename = f"{spec_id}.md"
source_path = cli.project_dir / "specs" / "change" / filename
source_path.parent.mkdir(parents=True)
source_bytes = "# Historical change record\n\n你好,世界。\n".encode()
source_path.write_bytes(source_bytes)

by_path = cli.yaml("dump", str(source_path), "--dry-run")
by_id = cli.yaml("dump", spec_id, "--dry-run")
assert by_path == by_id
issue = by_path["issue"]
assert protocol_header(issue["body"]) == {
"zest-dev-issue-spec": 3,
"kind": "standalone-file",
"spec-id": spec_id,
"filename": filename,
}
assert issue["body"].endswith(source_bytes.decode())
assert issue["comments"] == []

source_path.rename(source_path.with_suffix(".source"))
dump_path = cli.project_dir / "standalone-v3.yml"
load_issue = {**issue, "comments": ["Archive discussion without protocol metadata."]}
dump_path.write_text(yaml.safe_dump(load_issue, sort_keys=False), encoding="utf-8")
loaded = cli.yaml("load", "--from-file", str(dump_path))

assert loaded["spec"] == {
"id": spec_id,
"path": f"specs/change/{filename}",
"active": False,
"status": "new",
}
assert source_path.read_bytes() == source_bytes


def test_v3_standalone_file_fails_fast_for_ambiguity_collisions_and_corruption(cli):
spec_id = "20260102-ambiguous-record"
filename = f"{spec_id}.md"
specs_dir = cli.project_dir / "specs" / "change"
standalone_path = specs_dir / filename
directory_path = specs_dir / spec_id
directory_path.mkdir(parents=True)
(directory_path / "notes.md").write_text("# Directory\n", encoding="utf-8")
standalone_path.write_text("# Standalone\n", encoding="utf-8")

assert "Ambiguous Spec identifier" in cli.fail("dump", spec_id, "--dry-run")
assert cli.yaml("dump", str(standalone_path), "--dry-run")["ok"] is True
assert cli.yaml("dump", str(directory_path), "--dry-run")["ok"] is True

shutil.rmtree(directory_path)
dumped = cli.yaml("dump", spec_id, "--dry-run")["issue"]
dump_path = cli.project_dir / "standalone-collision.yml"
dump_path.write_text(yaml.safe_dump(dumped, sort_keys=False), encoding="utf-8")
assert "Target standalone Spec file already exists" in cli.fail(
"load", "--from-file", str(dump_path)
)

standalone_path.unlink()
directory_path.mkdir()
assert "Conflicting target Spec directory already exists" in cli.fail(
"load", "--from-file", str(dump_path)
)

shutil.rmtree(directory_path)
invalid_cases = {
"wrong-kind": protocol_document(
{
"zest-dev-issue-spec": 3,
"kind": "directory",
"spec-id": spec_id,
"filename": filename,
},
"# Body\n",
),
"wrong-filename": protocol_document(
{
"zest-dev-issue-spec": 3,
"kind": "standalone-file",
"spec-id": spec_id,
"filename": "20260102-other.md",
},
"# Body\n",
),
"extra-metadata": protocol_document(
{
"zest-dev-issue-spec": 3,
"kind": "standalone-file",
"spec-id": spec_id,
"filename": filename,
"files": [filename],
},
"# Body\n",
),
}
for name, body in invalid_cases.items():
invalid_path = cli.project_dir / f"{name}.yml"
invalid_path.write_text(
yaml.safe_dump({"body": body, "comments": []}, sort_keys=False),
encoding="utf-8",
)
assert cli.run("load", "--from-file", str(invalid_path)).returncode != 0

protocol_comment_path = cli.project_dir / "protocol-comment.yml"
protocol_comment_path.write_text(
yaml.safe_dump(
{
"body": protocol_document(
{
"zest-dev-issue-spec": 3,
"kind": "standalone-file",
"spec-id": spec_id,
"filename": filename,
},
"# Body\n",
),
"comments": [
protocol_document(
{"zest-dev-issue-spec": 3, "spec-id": spec_id},
"# Unexpected protocol payload\n",
)
],
},
sort_keys=False,
),
encoding="utf-8",
)
assert "must not contain protocol comments" in cli.fail(
"load", "--from-file", str(protocol_comment_path)
)


def test_dump_rejects_invalid_utf8_standalone_file(cli):
path = cli.project_dir / "specs" / "change" / "20260103-invalid-utf8.md"
path.parent.mkdir(parents=True)
path.write_bytes(b"\xff")
assert "Invalid UTF-8 Markdown file" in cli.fail("dump", str(path), "--dry-run")


def test_dump_and_load_round_trip_yaml_sensitive_markdown_paths(cli):
created = cli.yaml("create", "yaml-sensitive-paths")["spec"]
source_id = created["id"]
Expand Down Expand Up @@ -319,13 +459,19 @@ def test_v2_manifest_fails_fast_for_incomplete_or_invalid_representations(cli):
assert expected_error in cli.fail("load", "--from-file", str(path))


@pytest.mark.parametrize("with_spec_md", [True, False])
def test_github_transport_uses_gh_and_reports_comment_failure(cli, with_spec_md):
@pytest.mark.parametrize("source_shape", ["directory-with-main", "directory-without-main", "standalone"])
def test_github_transport_uses_gh_and_reports_comment_failure(cli, source_shape):
created = cli.yaml("create", "github-dump-source")["spec"]
spec_dir = cli.project_dir / "specs" / "change" / created["id"]
(spec_dir / "notes.md").write_text("# Notes\n", encoding="utf-8")
if not with_spec_md:
spec_identifier = created["id"]
standalone_path = cli.project_dir / "specs" / "change" / f"{created['id']}.md"
if source_shape == "directory-without-main":
(spec_dir / "spec.md").unlink()
elif source_shape == "standalone":
shutil.rmtree(spec_dir)
standalone_path.write_text("# Standalone archive\n", encoding="utf-8")
spec_identifier = str(standalone_path)

fake_bin = cli.project_dir / "fake-bin"
fake_bin.mkdir()
Expand Down Expand Up @@ -373,35 +519,44 @@ def test_github_transport_uses_gh_and_reports_comment_failure(cli, with_spec_md)
fake_gh.chmod(fake_gh.stat().st_mode | stat.S_IXUSR)
env = {"PATH": f"{fake_bin}{os.pathsep}{os.environ['PATH']}", "GH_LOG": str(log_path)}

dumped = cli.yaml("dump", created["id"], env=env)
dumped = cli.yaml("dump", spec_identifier, env=env)
assert dumped["ok"] is True
assert dumped["issue"]["url"] == "https://github.com/nettee/zest-dev/issues/123"
assert dumped["issue"]["closed"] is True
log_entries = [yaml.safe_load(line) for line in log_path.read_text(encoding="utf-8").splitlines()]
assert log_entries[1]["args"][:2] == ["issue", "create"]
assert "--label" in log_entries[1]["args"]
assert log_entries[2]["args"][:2] == ["issue", "comment"]
if source_shape != "standalone":
assert log_entries[2]["args"][:2] == ["issue", "comment"]
assert log_entries[-1]["args"] == ["issue", "close", "https://github.com/nettee/zest-dev/issues/123"]

fail_env = {**env, "FAIL_COMMENT": "1"}
assert "created issue before failure: https://github.com/nettee/zest-dev/issues/123" in cli.fail(
"dump", created["id"], env=fail_env
)
if source_shape != "standalone":
assert "created issue before failure: https://github.com/nettee/zest-dev/issues/123" in cli.fail(
"dump", spec_identifier, env=fail_env
)

close_fail_env = {**env, "FAIL_CLOSE": "1"}
assert "created issue before failure: https://github.com/nettee/zest-dev/issues/123" in cli.fail(
"dump", created["id"], env=close_fail_env
"dump", spec_identifier, env=close_fail_env
)

dry_run = cli.yaml("dump", created["id"], "--dry-run")
dry_run = cli.yaml("dump", spec_identifier, "--dry-run")
body_path = cli.project_dir / "issue-body.md"
comments_path = cli.project_dir / "issue-comments.yml"
body_path.write_text(dry_run["issue"]["body"], encoding="utf-8")
comments_path.write_text(json.dumps(dry_run["issue"]["comments"]), encoding="utf-8")
load_env = {**env, "ISSUE_BODY": str(body_path), "ISSUE_COMMENTS": str(comments_path)}
source_files = markdown_files(spec_dir)
spec_dir.rename(spec_dir.with_name(f"{created['id']}.source"))
if source_shape == "standalone":
source_bytes = standalone_path.read_bytes()
standalone_path.rename(standalone_path.with_suffix(".source"))
else:
source_files = markdown_files(spec_dir)
spec_dir.rename(spec_dir.with_name(f"{created['id']}.source"))
loaded = cli.yaml("load", "123", env=load_env)
assert loaded["ok"] is True
assert loaded["source"] == {"type": "github", "issue": "123"}
assert markdown_files(cli.project_dir / "specs" / "change" / created["id"]) == source_files
if source_shape == "standalone":
assert standalone_path.read_bytes() == source_bytes
else:
assert markdown_files(cli.project_dir / "specs" / "change" / created["id"]) == source_files
Loading
Loading