Skip to content

pdd detect --stories --scope-manifest cannot validate a story whose prompt lives in a subdirectory #2379

Description

@agarwal-ishaan

Summary

A user story whose linked prompt lives in a subdirectory of the prompts root can never be validated in pdd detect --stories --scope-manifest mode. It always fails closed with scope:MANIFEST_MISMATCH before any provider call.

This is self-inflicted: pdd story link / pdd story add write the story metadata in a form that the manifest pre-flight gate cannot resolve, and manifest mode rejects the flag that would otherwise point the resolver at the right directory. Every prompt under prompts/<subdir>/ is affected, which today means prompts/core/, prompts/commands/, and any new package-style prompt folder.

Found while adding story coverage for a prompt split under prompts/conformance/ (PR #2374).

Reproduction

Self-contained, no network, no provider key:

import json, os, tempfile
from pathlib import Path
from pdd.commands.analysis import _load_scope_manifest, _scope_manifest_metadata_matches

STORY = """<!-- pdd-story-prompts: {refs} -->

# User Story: demo

## Story
As a user, I can do the thing, so that the benefit follows.
"""

def build(root: Path, refs: str) -> Path:
    (root / "prompts" / "conformance").mkdir(parents=True)
    (root / "prompts" / "conformance" / "demo_python.prompt").write_text("% demo\n")
    (root / "user_stories" / "contracts").mkdir(parents=True)
    (root / "user_stories" / "story__demo.md").write_text(STORY.format(refs=refs))
    (root / "user_stories" / "contracts" / "demo.contract.md").write_text(
        "# Contract: demo\n\n## Covers\n- AC1: the thing\n")
    manifest = root / "scope.json"
    manifest.write_text(json.dumps({
        "schema_version": "pdd.detect.stories.scope.v1",
        "stories": [{
            "story": "user_stories/story__demo.md",
            "contract": "user_stories/contracts/demo.contract.md",
            "prompts": ["prompts/conformance/demo_python.prompt"],
        }],
    }))
    return manifest

for label, refs in [
    ("basename (what `pdd story link` writes)", "demo_python.prompt"),
    ("repo-relative path (manual edit)", "prompts/conformance/demo_python.prompt"),
]:
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp).resolve()
        manifest = build(root, refs)
        os.chdir(root)
        ok = _scope_manifest_metadata_matches(_load_scope_manifest(manifest))
        print(f"{label:44} {'accepted' if ok else 'REJECTED -> scope:MANIFEST_MISMATCH'}")

Actual:

basename (what `pdd story link` writes)      REJECTED -> scope:MANIFEST_MISMATCH
repo-relative path (manual edit)             accepted

Expected: both accepted. The two spellings name the same file.

Root cause

Two defects that only bite in combination.

A. Metadata degrades to a bare basename

_prompt_reference_for_metadata (pdd/user_story_tests.py:316-321):

if prompts_dir:
    try:
        return prompt_path.relative_to(prompts_dir).as_posix()
    except ValueError:
        pass
return prompt_path.name

The caller resolves the prompt paths ({pf.resolve() for pf in linked_prompt_paths}, _upsert_story_prompt_metadata line 332) but does not resolve prompts_dir. When the prompts root is a symlink — as it is in this repo, prompts -> pdd/promptsrelative_to raises and the code silently falls back to the basename:

prompts (symlink, as passed)     -> ValueError  =>  falls back to 'gate_errors_python.prompt'
prompts resolved                 -> conformance/gate_errors_python.prompt

The basename is lossy for anything in a subdirectory, and the fallback is silent.

B. The manifest pre-flight gate never looks in a subdirectory

_scope_manifest_metadata_matches (pdd/commands/analysis.py:192-195) tries exactly two candidates:

for candidate in (
    scope.project_root / reference,
    scope.project_root / Path(reference).name,
):

For demo_python.prompt both are <root>/demo_python.prompt, which does not exist. No prompts root is consulted, and there is no recursive search — so a bare basename for a subdirectory prompt can never match.

pdd/story_detection_result.py:343-347 has a third candidate (prompts_dir / Path(prompt_ref).name), but that is the post-evaluation resolver, it is still not recursive, and in manifest mode prompts_dir is pinned to ..

C. No escape hatch

pdd/commands/analysis.py:403 rejects the one flag that could redirect the resolver:

Error: --scope-manifest cannot be combined with --stories-dir or --prompts-dir.

So the user cannot work around B from the CLI at all.

Impact

  • Scope-manifest mode is the documented path for hosted and monorepo validation (docs/generating_user_stories.md, Step 6), and it is precisely the layout — nested prompt packages — where directory discovery is "too broad" and a manifest is most needed.
  • It fails closed, so it is not a silent-wrong-answer bug. But it is indistinguishable from a genuine scope violation, and the message ("Story prompt metadata does not match the exact scope manifest") does not hint that a basename/subdirectory mismatch is the cause.
  • The only workaround is to hand-edit the <!-- pdd-story-prompts: ... --> comment to a repo-relative path, which the next pdd story link will silently revert.

Suggested fix

Smallest correct change is A: resolve prompts_dir before relative_to, so the metadata keeps conformance/demo_python.prompt instead of degrading to a basename.

if prompts_dir:
    try:
        return prompt_path.relative_to(Path(prompts_dir).resolve()).as_posix()
    except ValueError:
        pass

Worth doing alongside it:

  • B — make the pre-flight resolver consult the prompts root and match a bare basename against a unique recursive hit, failing closed only when the basename is genuinely ambiguous. That also makes existing stories with basename metadata work without rewriting them.
  • C — either allow --prompts-dir with --scope-manifest (it narrows rather than widens the authorization boundary), or say in the MANIFEST_MISMATCH message which references failed to resolve and what spelling was expected.
  • A regression test with a prompt in a subdirectory. The existing coverage appears to use flat prompts/ layouts only, which is why this was not caught.

Related

  • Basename-keyed story linking bites elsewhere too: coverage_contracts._story_links_prompt matches on basename with the directory already discarded by _prompt_basename, so two prompts sharing a basename in different packages (prompts/core/errors_python.prompt vs prompts/conformance/errors_python.prompt) cross-count each other's story evidence with no path-qualified escape. Worth considering together if the linking layer is revisited — a fix to A alone does not address it.
  • pdd split: step-4 option parsing discards a valid proposal and cannot recover #2372 — also found while working on the same prompt split.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Status
    Backlog

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions