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
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<!-- docket:backlink:start (generated — do not hand-edit) -->
> ↩ **[Change 0460 — artifact.backlink refuses an absolute --change path with unknown-change](https://github.com/danielhanold/docket/blob/docket/docs/changes/active/0460-artifact-backlink-refuses-an-absolute-change-path-with-unkno.md)**
<!-- docket:backlink:end -->
# artifact.backlink refuses an absolute --change path with unknown-change — Results

**Human action:** None required. The fix is covered by automated tests; a reviewer only needs to read the PR diff.

## Outcome

`docket artifact backlink --change <path>` used to answer `unknown-change` when given an absolute path or an oddly spelled one (`./docs/...`, `a//b`, a `..` escape). That made it look like the change record was missing, when the real problem was the path's form. The command now checks `--change` the same way `--artifact` and the plan/results attach commands already check their paths:

- an absolute path is refused with reason `absolute-path`;
- an empty value, a `..` escape, or a non-canonical spelling is refused with reason `path-escape`;
- each message names the `--change` flag and shows the expected repo-relative form.

Only a well-formed repo-relative path that matches no record still returns `unknown-change`. Nothing is written when any of these refusals fires. Absolute paths are still not accepted, as the spec decided.

The `docket-implement-next` skill text that led an agent to pass an absolute path now says "repo-relative" at both places it passes `artifact.backlink` flags, and the embedded copy of that skill was regenerated.

## Verification performed

- New table test `TestArtifactBacklinkChangePathValidation` covers absolute, `..` escape, `./` spelling, interior `..`, trailing slash, empty and whitespace-only values. It failed before the fix (all reported `unknown-change`) and passes after it. The artifact file is checked byte-identical after each refusal.
- Mutation check: removing the absolute-path branch made the `absolute` case fail, as intended.
- The embedded-asset drift guard and harness golden tests pass after regeneration.
- The full suite runs at the build gate; its result is recorded in the PR's build-evidence block.

Large diffs are not rendered by default.

63 changes: 54 additions & 9 deletions internal/app/artifact_backlink.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"fmt"
"os"
"path"
"path/filepath"
"strings"

Expand Down Expand Up @@ -42,11 +43,13 @@ const (
// The stable machine reasons `artifact backlink` reports for its typed refusals.
// Message text is explanatory and must not be parsed.
const (
// ReasonBacklinkAbsolutePath: the artifact path is absolute; paths crossing
// the CLI are canonical repository-relative (Global Constraints).
// ReasonBacklinkAbsolutePath: the artifact or change path is absolute;
// paths crossing the CLI are canonical repository-relative (Global
// Constraints).
ReasonBacklinkAbsolutePath = "absolute-path"
// ReasonBacklinkPathEscape: the artifact path escapes the worktree with a
// `..` traversal.
// `..` traversal, or the change path is empty, escaping, or a
// non-canonical spelling.
ReasonBacklinkPathEscape = "path-escape"
// ReasonBacklinkSymlinkEscape: a symlink hop on the artifact path resolves to
// a physical location outside the worktree.
Expand All @@ -58,8 +61,8 @@ const (
// malformed (dangling/out-of-order/nested markers); the block is not rewritten
// and the file is left untouched.
ReasonBacklinkMalformedMarkers = "malformed-markers"
// ReasonBacklinkUnknownChange: the --change path names no record in the
// corpus, so no backlink can be rendered.
// ReasonBacklinkUnknownChange: the well-formed --change path names no
// record in the corpus, so no backlink can be rendered.
ReasonBacklinkUnknownChange = "unknown-change"
// ReasonBacklinkRepoUnreadable: the worktree root cannot be canonicalised.
ReasonBacklinkRepoUnreadable = "repo-unreadable"
Expand Down Expand Up @@ -168,21 +171,28 @@ func ArtifactBacklink(ctx context.Context, deps PlanningDeps, repoDir string, re
fmt.Sprintf("artifact %q has a malformed managed-block population: %v", req.ArtifactPath, err))
}

// 4. Resolve the target change from one pinned corpus read.
// 4. Validate --change lexically before the corpus read: a malformed
// spelling is refused by form, so unknown-change is reached only by a
// well-formed path that names no record.
if reason, msg := validateBacklinkChangePath(req.ChangePath); reason != "" {
return backlinkRefusal(ResultInvalidInput, reason, msg)
}

// 5. Resolve the target change from one pinned corpus read.
change, refusal := resolveBacklinkChange(ctx, deps, repoDir, req.ChangePath)
if refusal != nil {
return *refusal
}

// 5. Render the deterministic backlink block and reduce it to the interior the
// 6. Render the deterministic backlink block and reduce it to the interior the
// document layer manages between the markers it owns.
block, err := render.BacklinkContent(change.change, change.link)
if err != nil {
return backlinkRefusal(ResultInternalError, ReasonStatusInternalError, err.Error())
}
interior := backlinkInterior(block)

// 6. Rewrite (or insert) the managed block.
// 7. Rewrite (or insert) the managed block.
var ps document.PatchSet
if _, ok := doc.Block(backlinkBlockName); ok {
ps.ReplaceBlock(backlinkBlockName, interior)
Expand All @@ -201,7 +211,7 @@ func ArtifactBacklink(ctx context.Context, deps PlanningDeps, repoDir string, re

applied := ArtifactBacklinkResult{Artifact: req.ArtifactPath, Change: change.change.Path()}

// 7. Idempotent write: unchanged bytes are a no-op, so a re-run yields a
// 8. Idempotent write: unchanged bytes are a no-op, so a re-run yields a
// byte-identical file and no needless mtime churn.
if string(updated) == string(original) {
applied.Disposition = backlinkDispositionUnchanged
Expand Down Expand Up @@ -332,3 +342,38 @@ func resolveEveryHop(p string) (string, error) {
}
return filepath.Join(parentReal, filepath.Base(p)), nil
}

// validateBacklinkChangePath proves the --change value is a canonical
// repository-relative path — the one rule every path flag crossing the CLI
// follows (--artifact above, verifyAttachPath in change_attach.go). The check
// is purely lexical: the change record lives in the pinned git corpus, not on
// the feature worktree's filesystem, so there is no containment root to
// resolve against and no symlink to canonicalise. It mirrors verifyAttachPath
// deliberately (learning duplicated-gate-copies-the-whole-predicate: all four
// checks, not just the absolute-path threshold) without factoring it out, so
// the attach operations' observable reasons and messages stay untouched. It
// returns a stable refusal reason and a message naming the flag and the
// expected form, or ("", "") for a well-formed path.
func validateBacklinkChangePath(changePath string) (string, string) {
const form = "pass the canonical repository-relative change path (e.g. docs/changes/active/<id>-<slug>.md)"
if strings.TrimSpace(changePath) == "" {
return ReasonBacklinkPathEscape,
fmt.Sprintf("--change path is empty; %s", form)
}
if filepath.IsAbs(changePath) {
return ReasonBacklinkAbsolutePath,
fmt.Sprintf("--change path %q is absolute; %s", changePath, form)
}
if !filepath.IsLocal(filepath.FromSlash(changePath)) {
return ReasonBacklinkPathEscape,
fmt.Sprintf("--change path %q escapes the repository root; %s", changePath, form)
}
// Clean is a no-op for a canonical path; an input that changes under Clean
// is non-canonical (a `./`, `//`, interior `..`, or trailing-slash
// spelling) and is refused as an escape, matching verifyAttachPath.
if clean := path.Clean(changePath); clean != changePath {
return ReasonBacklinkPathEscape,
fmt.Sprintf("--change path %q is not in canonical repository-relative form; %s", changePath, form)
}
return "", ""
}
62 changes: 62 additions & 0 deletions internal/app/artifact_backlink_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -235,3 +235,65 @@ func TestArtifactBacklinkUnknownChange(t *testing.T) {
t.Fatalf("file mutated for an unknown change: %q", out)
}
}

// TestArtifactBacklinkChangePathValidation: --change is validated as a
// canonical repository-relative path before the corpus read — the same rule
// --artifact and the attach operations enforce. A malformed spelling is a
// typed refusal naming the flag and the expected form, never unknown-change,
// and the artifact is left byte-identical.
func TestArtifactBacklinkChangePathValidation(t *testing.T) {
pin := docketPin(t)
corpus := backlinkCorpus()

cases := []struct {
name string
changePath string
reason string
}{
// The 0458 shape: an absolute spelling of a path whose repo-relative
// tail names a real record must refuse, never resolve.
{"absolute", "/work/repo/" + backlinkChangePath, ReasonBacklinkAbsolutePath},
{"dotdot-escape", "../" + backlinkChangePath, ReasonBacklinkPathEscape},
{"non-canonical-dot", "./" + backlinkChangePath, ReasonBacklinkPathEscape},
{"interior-dotdot", "docs/changes/active/../active/0315-claim.md", ReasonBacklinkPathEscape},
{"trailing-slash", backlinkChangePath + "/", ReasonBacklinkPathEscape},
{"empty", "", ReasonBacklinkPathEscape},
{"whitespace-only", " ", ReasonBacklinkPathEscape},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
root := testsupport.TempDir(t)
artifact := filepath.Join(root, "plan.md")
original := []byte("# Plan\n\nAuthored body.\n")
if err := os.WriteFile(artifact, original, 0o644); err != nil {
t.Fatalf("seed artifact: %v", err)
}

got := ArtifactBacklink(context.Background(), backlinkDeps(&fakeReader{pin: pin, corpus: corpus}), root,
ArtifactBacklinkRequest{ArtifactPath: "plan.md", ChangePath: tc.changePath})

if got.Result != ResultInvalidInput {
t.Fatalf("result=%q, want %q (reason=%q message=%q)", got.Result, ResultInvalidInput, got.Reason, got.Message)
}
if got.Reason != tc.reason {
t.Fatalf("reason=%q, want %q (message=%q)", got.Reason, tc.reason, got.Message)
}
// The message must name the flag and the expected form — the 0458
// failure was precisely a message that named neither.
if !strings.Contains(got.Message, "--change") {
t.Fatalf("message does not name the --change flag: %q", got.Message)
}
if !strings.Contains(got.Message, "repository-relative") {
t.Fatalf("message does not name the expected form: %q", got.Message)
}
// Refusal predates any write.
out, err := os.ReadFile(artifact)
if err != nil {
t.Fatalf("read back: %v", err)
}
if string(out) != string(original) {
t.Fatalf("file mutated on refusal:\n got %q\nwant %q", out, original)
}
})
}
}
6 changes: 3 additions & 3 deletions internal/assets/embedded/manifest.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading