prodc 0.3.4__tar.gz → 0.3.6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -32,12 +32,12 @@ logs/
32
32
  # Personal, machine-specific agent overrides (AGENTS.md + CLAUDE.md are tracked)
33
33
  CLAUDE.local.md
34
34
 
35
- # Local agent wiring: names cabildo-issues-mcp, which is not a dependency of this
36
- # package. docs/issues/ IS tracked — the register is plain markdown anyone can read.
35
+ # Local MCP/agent wiring — not a dependency of this package. docs/issues/ IS
36
+ # tracked — the register is plain markdown anyone can read.
37
37
  .mcp.json
38
38
 
39
39
  # Local agent runtime state (scheduled-task locks, session scratch)
40
40
  .claude/
41
41
 
42
- # Local multi-agent coordination config (cabildo) — not part of the package
42
+ # Local multi-agent coordination state — not part of the package
43
43
  .cabildo/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: prodc
3
- Version: 0.3.4
3
+ Version: 0.3.6
4
4
  Summary: Product management as code: personas, flows, and features traced to evidence.
5
5
  Project-URL: Homepage, https://gitlab.com/jorgeecardona/prodc
6
6
  Project-URL: Repository, https://gitlab.com/jorgeecardona/prodc
@@ -66,6 +66,6 @@ new grammar.
66
66
  ## Layout
67
67
 
68
68
  - `src/prodc/` — the package (typed; `py.typed`).
69
- - `docs/issues/` — follow-ups (the `cabildo issues` convention).
69
+ - `docs/issues/` — follow-ups, as a plain-markdown issue register.
70
70
  - `Makefile` — `format` / `lint` / `typecheck` / `test` / `check` / `build` / `docs`.
71
71
  - `AGENTS.md` — build/test/style/gotchas for coding agents.
@@ -40,6 +40,6 @@ new grammar.
40
40
  ## Layout
41
41
 
42
42
  - `src/prodc/` — the package (typed; `py.typed`).
43
- - `docs/issues/` — follow-ups (the `cabildo issues` convention).
43
+ - `docs/issues/` — follow-ups, as a plain-markdown issue register.
44
44
  - `Makefile` — `format` / `lint` / `typecheck` / `test` / `check` / `build` / `docs`.
45
45
  - `AGENTS.md` — build/test/style/gotchas for coding agents.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "prodc"
3
- version = "0.3.4"
3
+ version = "0.3.6"
4
4
  description = "Product management as code: personas, flows, and features traced to evidence."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -243,3 +243,74 @@ class FilesAdapter:
243
243
  if not fp.exists():
244
244
  return ProofResult(ProofState.BROKEN, False, f"no file '{payload}'")
245
245
  return ProofResult(ProofState.DELIVERED, True, "script exists")
246
+
247
+
248
+ class AcceptAdapter:
249
+ """Resolves ``who@date`` to a human sign-off — a functional acceptance, not a test.
250
+
251
+ Some needs are accepted by a person, not proven by an automated test. This records
252
+ that as a *declared* DELIVERED (``*``), visibly distinct from a machine-checked proof;
253
+ it never goes BROKEN, because it is an assertion, like a tag.
254
+ """
255
+
256
+ def resolve(self, payload: str) -> ProofResult:
257
+ who, _, date = payload.partition("@")
258
+ detail = "accepted" + (f" by {who}" if who else "") + (f" on {date}" if date else "")
259
+ return ProofResult(ProofState.DELIVERED, True, detail)
260
+
261
+
262
+ @dataclass(frozen=True)
263
+ class SourceResult:
264
+ """Whether a Source.locator resolves. Verifies provenance *exists*, not that it is good."""
265
+
266
+ ok: bool
267
+ detail: str
268
+
269
+
270
+ class SourceAdapter(Protocol):
271
+ def resolve(self, payload: str) -> SourceResult: ...
272
+
273
+
274
+ def _slug(text: str) -> str:
275
+ return re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
276
+
277
+
278
+ class FrontmatterAdapter:
279
+ """Resolves ``file#anchor`` against markdown files (e.g. docs/issues/*.md).
280
+
281
+ Confirms a heading matching the anchor exists, so a Source citing a ticket/section
282
+ that is absent or renamed surfaces as a dangling (BROKEN) source — the provenance-side
283
+ analogue of a BROKEN proof.
284
+ """
285
+
286
+ def __init__(self, root: Path, paths: list[str]) -> None:
287
+ self._root = root
288
+ self._paths = paths
289
+ self._headings: list[tuple[str, str]] | None = None # (file name, heading text)
290
+
291
+ def _load(self) -> list[tuple[str, str]]:
292
+ if self._headings is not None:
293
+ return self._headings
294
+ found: list[tuple[str, str]] = []
295
+ heading_re = re.compile(r"^#{1,6}\s+(.*?)\s*$")
296
+ for pattern in self._paths:
297
+ for fp in sorted(self._root.glob(pattern)):
298
+ for line in fp.read_text(encoding="utf-8").splitlines():
299
+ m = heading_re.match(line)
300
+ if m:
301
+ found.append((fp.name, m.group(1)))
302
+ self._headings = found
303
+ return found
304
+
305
+ def resolve(self, payload: str) -> SourceResult:
306
+ file_part, _, anchor = payload.partition("#")
307
+ if not anchor:
308
+ return SourceResult(False, f"no anchor in '{payload}'")
309
+ want = _slug(anchor)
310
+ for fname, heading in self._load():
311
+ if file_part and fname != file_part and not fname.endswith("/" + file_part):
312
+ continue
313
+ hslug = _slug(heading)
314
+ if hslug == want or hslug.startswith(want + "-") or anchor.lower() in heading.lower():
315
+ return SourceResult(True, f"{fname}: '{heading}'")
316
+ return SourceResult(False, f"no heading matches '{payload}'")
@@ -39,8 +39,15 @@ def _priority(r: NeedResult) -> int:
39
39
  }[r.state]
40
40
 
41
41
 
42
- # Warrant strength, strongest first: interview > artifact > internal (Torres).
43
- _WARRANT_ORDER = (SourceKind.INTERVIEW, SourceKind.ARTIFACT, SourceKind.INTERNAL)
42
+ # Warrant strength, strongest first: interview > artifact > proxy > internal (Torres).
43
+ # proxy = the author is a plausible user guessing as that user — weaker than a document,
44
+ # stronger than a bare internal assertion.
45
+ _WARRANT_ORDER = (
46
+ SourceKind.INTERVIEW,
47
+ SourceKind.ARTIFACT,
48
+ SourceKind.PROXY,
49
+ SourceKind.INTERNAL,
50
+ )
44
51
 
45
52
 
46
53
  def _warrant(need: Need) -> str:
@@ -57,7 +64,7 @@ def _warrant(need: Need) -> str:
57
64
 
58
65
  def _provenance_counts(results: list[NeedResult]) -> dict[str, int]:
59
66
  """How many needs carry each warrant strength (interview/artifact/internal/opinion)."""
60
- counts = {"interview": 0, "artifact": 0, "internal": 0, "opinion": 0}
67
+ counts = {"interview": 0, "artifact": 0, "proxy": 0, "internal": 0, "opinion": 0}
61
68
  for r in results:
62
69
  counts[_warrant(r.need).split(":", 1)[0]] += 1
63
70
  return counts
@@ -194,7 +201,7 @@ def _cmd_status(args: argparse.Namespace) -> int:
194
201
  if loaded is None:
195
202
  return 2
196
203
  cfg, needs = loaded
197
- results = resolve(needs, cfg.adapters)
204
+ results = resolve(needs, cfg.adapters, cfg.source_adapters)
198
205
  summary = summarize(results)
199
206
  orphan_map = orphans(needs, cfg.adapters)
200
207
 
@@ -299,7 +306,7 @@ def _cmd_show(args: argparse.Namespace) -> int:
299
306
  if loaded is None:
300
307
  return 2
301
308
  cfg, needs = loaded
302
- by_id = {r.need.id: r for r in resolve(needs, cfg.adapters)}
309
+ by_id = {r.need.id: r for r in resolve(needs, cfg.adapters, cfg.source_adapters)}
303
310
  r = by_id.get(args.need_id)
304
311
  if r is None:
305
312
  print(f"prodc: no need '{args.need_id}'", file=sys.stderr)
@@ -330,7 +337,7 @@ def _repo_row(config_path: str) -> _RepoRow:
330
337
  needs = load_needs(cfg)
331
338
  except (ConfigError, LoaderError, OSError) as exc:
332
339
  return _RepoRow(name, 0, 0, 0, 0, 0.0, {}, error=str(exc))
333
- results = resolve(needs, cfg.adapters)
340
+ results = resolve(needs, cfg.adapters, cfg.source_adapters)
334
341
  s = summarize(results)
335
342
  return _RepoRow(
336
343
  repo=cfg.root.name,
@@ -344,13 +351,13 @@ def _repo_row(config_path: str) -> _RepoRow:
344
351
 
345
352
 
346
353
  def _render_portfolio(rows: list[_RepoRow]) -> str:
347
- out = [f" {'repo':<18} {'cov':>4} {'must-HOLE':>9} {'BROKEN':>6} prov intv/art/int/op"]
354
+ out = [f" {'repo':<18} {'cov':>4} {'must-HOLE':>9} {'BROKEN':>6} prov intv/art/prx/int/op"]
348
355
  for row in rows:
349
356
  if row.error is not None:
350
357
  out.append(f" {row.repo:<18} error: {row.error}")
351
358
  continue
352
359
  p = row.provenance
353
- prov = f"{p['interview']}/{p['artifact']}/{p['internal']}/{p['opinion']}"
360
+ prov = f"{p['interview']}/{p['artifact']}/{p['proxy']}/{p['internal']}/{p['opinion']}"
354
361
  cov = f"{round(row.coverage * 100)}%"
355
362
  out.append(
356
363
  f" {row.repo:<18} {cov:>4} {row.must_hole:>9} {row.broken:>6} {prov}"
@@ -7,7 +7,15 @@ from dataclasses import dataclass, field
7
7
  from pathlib import Path
8
8
  from typing import cast
9
9
 
10
- from .adapters import Adapter, FilesAdapter, GherkinAdapter, PytestAdapter
10
+ from .adapters import (
11
+ AcceptAdapter,
12
+ Adapter,
13
+ FilesAdapter,
14
+ FrontmatterAdapter,
15
+ GherkinAdapter,
16
+ PytestAdapter,
17
+ SourceAdapter,
18
+ )
11
19
 
12
20
 
13
21
  class ConfigError(Exception):
@@ -34,6 +42,7 @@ class Config:
34
42
  root: Path
35
43
  need_globs: list[str]
36
44
  adapters: dict[str, Adapter] = field(default_factory=dict[str, Adapter])
45
+ source_adapters: dict[str, SourceAdapter] = field(default_factory=dict[str, SourceAdapter])
37
46
 
38
47
  @classmethod
39
48
  def load(cls, path: Path) -> Config:
@@ -73,8 +82,29 @@ class Config:
73
82
  elif kind == "files":
74
83
  sub = tbl.get("root", ".")
75
84
  adapters[name] = FilesAdapter(root=root / (sub if isinstance(sub, str) else "."))
85
+ elif kind == "accept":
86
+ adapters[name] = AcceptAdapter()
76
87
  else:
77
88
  raise ConfigError(
78
- f"[proofs.{name}]: unknown kind {kind!r} (gherkin|pytest|files)"
89
+ f"[proofs.{name}]: unknown kind {kind!r} (gherkin|pytest|files|accept)"
79
90
  )
80
- return cls(root=root, need_globs=need_globs, adapters=adapters)
91
+
92
+ source_adapters: dict[str, SourceAdapter] = {}
93
+ sources = _as_dict(raw.get("sources", {}), "[sources]")
94
+ for name, body in sources.items():
95
+ tbl = _as_dict(body, f"[sources.{name}]")
96
+ kind = tbl.get("kind")
97
+ if kind == "frontmatter":
98
+ source_adapters[name] = FrontmatterAdapter(
99
+ root=root,
100
+ paths=_as_str_list(tbl.get("paths", []), f"[sources.{name}].paths"),
101
+ )
102
+ else:
103
+ raise ConfigError(f"[sources.{name}]: unknown kind {kind!r} (frontmatter)")
104
+
105
+ return cls(
106
+ root=root,
107
+ need_globs=need_globs,
108
+ adapters=adapters,
109
+ source_adapters=source_adapters,
110
+ )
@@ -17,6 +17,7 @@ class SourceKind(StrEnum):
17
17
 
18
18
  INTERVIEW = "interview" # behavioural evidence: a named customer, a date, a verbatim quote
19
19
  ARTIFACT = "artifact" # documentary: a ticket, doc or email locator
20
+ PROXY = "proxy" # the author is a plausible user of this product, guessing as that user
20
21
  INTERNAL = "internal" # a team member's own assertion — provenance, but not a customer's
21
22
 
22
23
 
@@ -8,7 +8,7 @@ from __future__ import annotations
8
8
 
9
9
  from dataclasses import dataclass
10
10
 
11
- from .adapters import Adapter, Enumerable, ProofResult
11
+ from .adapters import Adapter, Enumerable, ProofResult, SourceAdapter
12
12
  from .model import (
13
13
  SOLUTION_VERBS,
14
14
  Need,
@@ -36,10 +36,36 @@ def _resolve_proof(locator: str, adapters: dict[str, Adapter]) -> ProofResult:
36
36
  return adapter.resolve(payload)
37
37
 
38
38
 
39
+ def _source_warnings(need: Need, source_adapters: dict[str, SourceAdapter]) -> list[str]:
40
+ """Flag a Source.locator that names a configured source adapter but does not resolve.
41
+
42
+ This is a dangling (BROKEN) *source* — distinct from OPINION (no source at all). A
43
+ free-text locator with no matching adapter is left unchecked, not flagged.
44
+ """
45
+ out: list[str] = []
46
+ for s in need.sources:
47
+ if not s.locator:
48
+ continue
49
+ name, sep, payload = s.locator.partition(":")
50
+ adapter = source_adapters.get(name)
51
+ if not sep or adapter is None:
52
+ continue
53
+ res = adapter.resolve(payload)
54
+ if not res.ok:
55
+ out.append(f"BROKEN source '{s.locator}' ({res.detail})")
56
+ return out
57
+
58
+
39
59
  class Resolver:
40
- def __init__(self, needs: list[Need], adapters: dict[str, Adapter]) -> None:
60
+ def __init__(
61
+ self,
62
+ needs: list[Need],
63
+ adapters: dict[str, Adapter],
64
+ source_adapters: dict[str, SourceAdapter] | None = None,
65
+ ) -> None:
41
66
  self._by_id = {n.id: n for n in needs}
42
67
  self._adapters = adapters
68
+ self._source_adapters = source_adapters or {}
43
69
  self._cache: dict[str, NeedResult] = {}
44
70
  self._visiting: set[str] = set()
45
71
 
@@ -55,7 +81,11 @@ class Resolver:
55
81
  self._visiting.add(need_id)
56
82
  result = self._compute(need)
57
83
  self._visiting.discard(need_id)
58
- result.warnings = _text_warnings(need) + result.warnings
84
+ result.warnings = (
85
+ _text_warnings(need)
86
+ + _source_warnings(need, self._source_adapters)
87
+ + result.warnings
88
+ )
59
89
  self._cache[need_id] = result
60
90
  return result
61
91
 
@@ -105,9 +135,13 @@ class Resolver:
105
135
  return NeedResult(need, state, delivered, total, declared, detail, warnings)
106
136
 
107
137
 
108
- def resolve(needs: list[Need], adapters: dict[str, Adapter]) -> list[NeedResult]:
138
+ def resolve(
139
+ needs: list[Need],
140
+ adapters: dict[str, Adapter],
141
+ source_adapters: dict[str, SourceAdapter] | None = None,
142
+ ) -> list[NeedResult]:
109
143
  """Resolve every need to a :class:`NeedResult`."""
110
- return Resolver(needs, adapters).resolve_all()
144
+ return Resolver(needs, adapters, source_adapters).resolve_all()
111
145
 
112
146
 
113
147
  @dataclass(frozen=True)
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  from pathlib import Path
6
6
 
7
- from prodc.adapters import FilesAdapter, PytestAdapter
7
+ from prodc.adapters import AcceptAdapter, FilesAdapter, FrontmatterAdapter, PytestAdapter
8
8
  from prodc.model import ProofState
9
9
 
10
10
 
@@ -52,3 +52,29 @@ def test_files_adapter_existence_is_declared(tmp_path: Path) -> None:
52
52
 
53
53
  def test_files_adapter_missing_is_broken(tmp_path: Path) -> None:
54
54
  assert FilesAdapter(root=tmp_path).resolve("gone.py").state is ProofState.BROKEN
55
+
56
+
57
+ def _frontmatter(tmp_path: Path) -> FrontmatterAdapter:
58
+ (tmp_path / "issues").mkdir()
59
+ (tmp_path / "issues" / "reg.md").write_text(
60
+ "# A-1 Partida doble\n\nbody\n\n## A-6 Factura electrónica\n", encoding="utf-8"
61
+ )
62
+ return FrontmatterAdapter(root=tmp_path, paths=["issues/*.md"])
63
+
64
+
65
+ def test_frontmatter_resolves_existing_heading(tmp_path: Path) -> None:
66
+ a = _frontmatter(tmp_path)
67
+ assert a.resolve("reg.md#A-1").ok
68
+ assert a.resolve("reg.md#A-6").ok # matches "## A-6 Factura electrónica"
69
+
70
+
71
+ def test_frontmatter_dangling_anchor_or_no_anchor(tmp_path: Path) -> None:
72
+ a = _frontmatter(tmp_path)
73
+ assert not a.resolve("reg.md#A-9").ok # no such heading
74
+ assert not a.resolve("reg.md#").ok # no anchor at all
75
+
76
+
77
+ def test_accept_adapter_is_declared_delivered() -> None:
78
+ r = AcceptAdapter().resolve("jane@2026-09-20")
79
+ assert r.state is ProofState.DELIVERED and r.declared # a human sign-off, not a test
80
+ assert "accepted by jane" in r.detail and "2026-09-20" in r.detail
@@ -177,3 +177,21 @@ def test_portfolio_bad_config_exits_2(tmp_path: Path, capsys: pytest.CaptureFixt
177
177
  code = main(["portfolio", str(tmp_path / "nope.toml")])
178
178
  capsys.readouterr()
179
179
  assert code == 2
180
+
181
+
182
+ def test_proxy_source_kind_warrant(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
183
+ import json
184
+
185
+ (tmp_path / "needs").mkdir()
186
+ (tmp_path / "prodc.toml").write_text('[prodc]\nneeds = ["needs/*.yaml"]\n', encoding="utf-8")
187
+ (tmp_path / "needs" / "a.yaml").write_text(
188
+ "who: me\nneeds:\n - id: P-1\n text: a proxy-sourced need\n"
189
+ " sources:\n - kind: proxy\n who: me\n date: 2026-01-01\n",
190
+ encoding="utf-8",
191
+ )
192
+ toml = str(tmp_path / "prodc.toml")
193
+ main(["status", "--config", toml, "--json"])
194
+ p = json.loads(capsys.readouterr().out)
195
+ assert p["needs"][0]["warrant"] == "proxy" # interview > artifact > proxy > internal
196
+ main(["status", "--config", toml])
197
+ assert "1 proxy" in capsys.readouterr().out # provenance line counts proxy
@@ -3,9 +3,10 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from dataclasses import dataclass
6
+ from pathlib import Path
6
7
 
7
- from prodc.adapters import ProofResult
8
- from prodc.model import Need, NeedState, ProofState
8
+ from prodc.adapters import FrontmatterAdapter, ProofResult, SourceAdapter
9
+ from prodc.model import Need, NeedState, ProofState, Source, SourceKind
9
10
  from prodc.status import resolve
10
11
 
11
12
 
@@ -62,3 +63,20 @@ def test_solution_verb_warns() -> None:
62
63
  need = Need(id="A-1", text="implement a dashboard", proofs=())
63
64
  (r,) = resolve([need], {})
64
65
  assert any("solution in disguise" in w for w in r.warnings)
66
+
67
+
68
+ def test_dangling_source_warns_distinct_from_opinion(tmp_path: Path) -> None:
69
+ (tmp_path / "issues").mkdir()
70
+ (tmp_path / "issues" / "reg.md").write_text("# A-1 ok\n", encoding="utf-8")
71
+ sources: dict[str, SourceAdapter] = {
72
+ "issues": FrontmatterAdapter(root=tmp_path, paths=["issues/*.md"])
73
+ }
74
+ good = Need(id="N-1", text="t",
75
+ sources=(Source(kind=SourceKind.ARTIFACT, locator="issues:reg.md#A-1"),))
76
+ bad = Need(id="N-2", text="t",
77
+ sources=(Source(kind=SourceKind.ARTIFACT, locator="issues:reg.md#NOPE"),))
78
+ results = {r.need.id: r for r in resolve([good, bad], {}, sources)}
79
+ assert not any("BROKEN source" in w for w in results["N-1"].warnings)
80
+ assert any("BROKEN source" in w for w in results["N-2"].warnings)
81
+ # both are sourced, so neither is an opinion — BROKEN-source is a distinct signal
82
+ assert not results["N-2"].need.is_opinion
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes