prodc 0.3.4__tar.gz → 0.3.5__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: prodc
3
- Version: 0.3.4
3
+ Version: 0.3.5
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "prodc"
3
- version = "0.3.4"
3
+ version = "0.3.5"
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,60 @@ 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
+ @dataclass(frozen=True)
249
+ class SourceResult:
250
+ """Whether a Source.locator resolves. Verifies provenance *exists*, not that it is good."""
251
+
252
+ ok: bool
253
+ detail: str
254
+
255
+
256
+ class SourceAdapter(Protocol):
257
+ def resolve(self, payload: str) -> SourceResult: ...
258
+
259
+
260
+ def _slug(text: str) -> str:
261
+ return re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
262
+
263
+
264
+ class FrontmatterAdapter:
265
+ """Resolves ``file#anchor`` against markdown files (e.g. docs/issues/*.md).
266
+
267
+ Confirms a heading matching the anchor exists, so a Source citing a ticket/section
268
+ that is absent or renamed surfaces as a dangling (BROKEN) source — the provenance-side
269
+ analogue of a BROKEN proof.
270
+ """
271
+
272
+ def __init__(self, root: Path, paths: list[str]) -> None:
273
+ self._root = root
274
+ self._paths = paths
275
+ self._headings: list[tuple[str, str]] | None = None # (file name, heading text)
276
+
277
+ def _load(self) -> list[tuple[str, str]]:
278
+ if self._headings is not None:
279
+ return self._headings
280
+ found: list[tuple[str, str]] = []
281
+ heading_re = re.compile(r"^#{1,6}\s+(.*?)\s*$")
282
+ for pattern in self._paths:
283
+ for fp in sorted(self._root.glob(pattern)):
284
+ for line in fp.read_text(encoding="utf-8").splitlines():
285
+ m = heading_re.match(line)
286
+ if m:
287
+ found.append((fp.name, m.group(1)))
288
+ self._headings = found
289
+ return found
290
+
291
+ def resolve(self, payload: str) -> SourceResult:
292
+ file_part, _, anchor = payload.partition("#")
293
+ if not anchor:
294
+ return SourceResult(False, f"no anchor in '{payload}'")
295
+ want = _slug(anchor)
296
+ for fname, heading in self._load():
297
+ if file_part and fname != file_part and not fname.endswith("/" + file_part):
298
+ continue
299
+ hslug = _slug(heading)
300
+ if hslug == want or hslug.startswith(want + "-") or anchor.lower() in heading.lower():
301
+ return SourceResult(True, f"{fname}: '{heading}'")
302
+ return SourceResult(False, f"no heading matches '{payload}'")
@@ -194,7 +194,7 @@ def _cmd_status(args: argparse.Namespace) -> int:
194
194
  if loaded is None:
195
195
  return 2
196
196
  cfg, needs = loaded
197
- results = resolve(needs, cfg.adapters)
197
+ results = resolve(needs, cfg.adapters, cfg.source_adapters)
198
198
  summary = summarize(results)
199
199
  orphan_map = orphans(needs, cfg.adapters)
200
200
 
@@ -299,7 +299,7 @@ def _cmd_show(args: argparse.Namespace) -> int:
299
299
  if loaded is None:
300
300
  return 2
301
301
  cfg, needs = loaded
302
- by_id = {r.need.id: r for r in resolve(needs, cfg.adapters)}
302
+ by_id = {r.need.id: r for r in resolve(needs, cfg.adapters, cfg.source_adapters)}
303
303
  r = by_id.get(args.need_id)
304
304
  if r is None:
305
305
  print(f"prodc: no need '{args.need_id}'", file=sys.stderr)
@@ -330,7 +330,7 @@ def _repo_row(config_path: str) -> _RepoRow:
330
330
  needs = load_needs(cfg)
331
331
  except (ConfigError, LoaderError, OSError) as exc:
332
332
  return _RepoRow(name, 0, 0, 0, 0, 0.0, {}, error=str(exc))
333
- results = resolve(needs, cfg.adapters)
333
+ results = resolve(needs, cfg.adapters, cfg.source_adapters)
334
334
  s = summarize(results)
335
335
  return _RepoRow(
336
336
  repo=cfg.root.name,
@@ -7,7 +7,14 @@ 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
+ Adapter,
12
+ FilesAdapter,
13
+ FrontmatterAdapter,
14
+ GherkinAdapter,
15
+ PytestAdapter,
16
+ SourceAdapter,
17
+ )
11
18
 
12
19
 
13
20
  class ConfigError(Exception):
@@ -34,6 +41,7 @@ class Config:
34
41
  root: Path
35
42
  need_globs: list[str]
36
43
  adapters: dict[str, Adapter] = field(default_factory=dict[str, Adapter])
44
+ source_adapters: dict[str, SourceAdapter] = field(default_factory=dict[str, SourceAdapter])
37
45
 
38
46
  @classmethod
39
47
  def load(cls, path: Path) -> Config:
@@ -77,4 +85,23 @@ class Config:
77
85
  raise ConfigError(
78
86
  f"[proofs.{name}]: unknown kind {kind!r} (gherkin|pytest|files)"
79
87
  )
80
- return cls(root=root, need_globs=need_globs, adapters=adapters)
88
+
89
+ source_adapters: dict[str, SourceAdapter] = {}
90
+ sources = _as_dict(raw.get("sources", {}), "[sources]")
91
+ for name, body in sources.items():
92
+ tbl = _as_dict(body, f"[sources.{name}]")
93
+ kind = tbl.get("kind")
94
+ if kind == "frontmatter":
95
+ source_adapters[name] = FrontmatterAdapter(
96
+ root=root,
97
+ paths=_as_str_list(tbl.get("paths", []), f"[sources.{name}].paths"),
98
+ )
99
+ else:
100
+ raise ConfigError(f"[sources.{name}]: unknown kind {kind!r} (frontmatter)")
101
+
102
+ return cls(
103
+ root=root,
104
+ need_globs=need_globs,
105
+ adapters=adapters,
106
+ source_adapters=source_adapters,
107
+ )
@@ -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 FilesAdapter, FrontmatterAdapter, PytestAdapter
8
8
  from prodc.model import ProofState
9
9
 
10
10
 
@@ -52,3 +52,23 @@ 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
@@ -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
File without changes
File without changes
File without changes
File without changes