prodc 0.3.3__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.
- {prodc-0.3.3 → prodc-0.3.5}/PKG-INFO +1 -1
- {prodc-0.3.3 → prodc-0.3.5}/pyproject.toml +1 -1
- {prodc-0.3.3 → prodc-0.3.5}/src/prodc/adapters.py +57 -0
- {prodc-0.3.3 → prodc-0.3.5}/src/prodc/cli.py +77 -5
- {prodc-0.3.3 → prodc-0.3.5}/src/prodc/config.py +29 -2
- {prodc-0.3.3 → prodc-0.3.5}/src/prodc/status.py +39 -5
- {prodc-0.3.3 → prodc-0.3.5}/tests/test_adapters.py +21 -1
- {prodc-0.3.3 → prodc-0.3.5}/tests/test_cli.py +29 -2
- {prodc-0.3.3 → prodc-0.3.5}/tests/test_status.py +20 -2
- {prodc-0.3.3 → prodc-0.3.5}/.gitignore +0 -0
- {prodc-0.3.3 → prodc-0.3.5}/CHANGELOG.md +0 -0
- {prodc-0.3.3 → prodc-0.3.5}/LICENSE +0 -0
- {prodc-0.3.3 → prodc-0.3.5}/README.md +0 -0
- {prodc-0.3.3 → prodc-0.3.5}/examples/quickstart/features/orders.feature +0 -0
- {prodc-0.3.3 → prodc-0.3.5}/examples/quickstart/needs/jordan.yaml +0 -0
- {prodc-0.3.3 → prodc-0.3.5}/examples/quickstart/prodc.toml +0 -0
- {prodc-0.3.3 → prodc-0.3.5}/src/prodc/__init__.py +0 -0
- {prodc-0.3.3 → prodc-0.3.5}/src/prodc/loader.py +0 -0
- {prodc-0.3.3 → prodc-0.3.5}/src/prodc/model.py +0 -0
- {prodc-0.3.3 → prodc-0.3.5}/src/prodc/py.typed +0 -0
- {prodc-0.3.3 → prodc-0.3.5}/tests/test_loader.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: prodc
|
|
3
|
-
Version: 0.3.
|
|
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
|
|
@@ -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}'")
|
|
@@ -11,6 +11,7 @@ import argparse
|
|
|
11
11
|
import json
|
|
12
12
|
import shutil
|
|
13
13
|
import sys
|
|
14
|
+
from dataclasses import asdict, dataclass
|
|
14
15
|
from pathlib import Path
|
|
15
16
|
|
|
16
17
|
from . import __version__
|
|
@@ -54,15 +55,21 @@ def _warrant(need: Need) -> str:
|
|
|
54
55
|
return strongest.value
|
|
55
56
|
|
|
56
57
|
|
|
58
|
+
def _provenance_counts(results: list[NeedResult]) -> dict[str, int]:
|
|
59
|
+
"""How many needs carry each warrant strength (interview/artifact/internal/opinion)."""
|
|
60
|
+
counts = {"interview": 0, "artifact": 0, "internal": 0, "opinion": 0}
|
|
61
|
+
for r in results:
|
|
62
|
+
counts[_warrant(r.need).split(":", 1)[0]] += 1
|
|
63
|
+
return counts
|
|
64
|
+
|
|
65
|
+
|
|
57
66
|
def _provenance_line(results: list[NeedResult]) -> str:
|
|
58
67
|
"""Per-persona provenance mix — makes internal-only ('0 OPINION' but unvalidated) visible.
|
|
59
68
|
|
|
60
69
|
OPINION (no source) stays distinct from a weak INTERNAL source; the headline a PM
|
|
61
70
|
wants is how many needs trace to a real user (interview), not just 'has any source'.
|
|
62
71
|
"""
|
|
63
|
-
counts =
|
|
64
|
-
for r in results:
|
|
65
|
-
counts[_warrant(r.need).split(":", 1)[0]] += 1
|
|
72
|
+
counts = _provenance_counts(results)
|
|
66
73
|
return "provenance: " + " · ".join(f"{counts[k]} {k}" for k in counts)
|
|
67
74
|
|
|
68
75
|
|
|
@@ -187,7 +194,7 @@ def _cmd_status(args: argparse.Namespace) -> int:
|
|
|
187
194
|
if loaded is None:
|
|
188
195
|
return 2
|
|
189
196
|
cfg, needs = loaded
|
|
190
|
-
results = resolve(needs, cfg.adapters)
|
|
197
|
+
results = resolve(needs, cfg.adapters, cfg.source_adapters)
|
|
191
198
|
summary = summarize(results)
|
|
192
199
|
orphan_map = orphans(needs, cfg.adapters)
|
|
193
200
|
|
|
@@ -292,7 +299,7 @@ def _cmd_show(args: argparse.Namespace) -> int:
|
|
|
292
299
|
if loaded is None:
|
|
293
300
|
return 2
|
|
294
301
|
cfg, needs = loaded
|
|
295
|
-
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)}
|
|
296
303
|
r = by_id.get(args.need_id)
|
|
297
304
|
if r is None:
|
|
298
305
|
print(f"prodc: no need '{args.need_id}'", file=sys.stderr)
|
|
@@ -301,6 +308,66 @@ def _cmd_show(args: argparse.Namespace) -> int:
|
|
|
301
308
|
return 0
|
|
302
309
|
|
|
303
310
|
|
|
311
|
+
@dataclass(frozen=True)
|
|
312
|
+
class _RepoRow:
|
|
313
|
+
"""One repo's line in the portfolio roll-up."""
|
|
314
|
+
|
|
315
|
+
repo: str
|
|
316
|
+
delivered: int
|
|
317
|
+
total: int
|
|
318
|
+
must_hole: int
|
|
319
|
+
broken: int
|
|
320
|
+
coverage: float
|
|
321
|
+
provenance: dict[str, int]
|
|
322
|
+
error: str | None = None
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _repo_row(config_path: str) -> _RepoRow:
|
|
326
|
+
p = Path(config_path)
|
|
327
|
+
name = p.parent.name or str(p)
|
|
328
|
+
try:
|
|
329
|
+
cfg = Config.load(p)
|
|
330
|
+
needs = load_needs(cfg)
|
|
331
|
+
except (ConfigError, LoaderError, OSError) as exc:
|
|
332
|
+
return _RepoRow(name, 0, 0, 0, 0, 0.0, {}, error=str(exc))
|
|
333
|
+
results = resolve(needs, cfg.adapters, cfg.source_adapters)
|
|
334
|
+
s = summarize(results)
|
|
335
|
+
return _RepoRow(
|
|
336
|
+
repo=cfg.root.name,
|
|
337
|
+
delivered=s.delivered,
|
|
338
|
+
total=s.total,
|
|
339
|
+
must_hole=s.must_hole,
|
|
340
|
+
broken=s.broken,
|
|
341
|
+
coverage=round(s.coverage, 4),
|
|
342
|
+
provenance=_provenance_counts(results),
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _render_portfolio(rows: list[_RepoRow]) -> str:
|
|
347
|
+
out = [f" {'repo':<18} {'cov':>4} {'must-HOLE':>9} {'BROKEN':>6} prov intv/art/int/op"]
|
|
348
|
+
for row in rows:
|
|
349
|
+
if row.error is not None:
|
|
350
|
+
out.append(f" {row.repo:<18} error: {row.error}")
|
|
351
|
+
continue
|
|
352
|
+
p = row.provenance
|
|
353
|
+
prov = f"{p['interview']}/{p['artifact']}/{p['internal']}/{p['opinion']}"
|
|
354
|
+
cov = f"{round(row.coverage * 100)}%"
|
|
355
|
+
out.append(
|
|
356
|
+
f" {row.repo:<18} {cov:>4} {row.must_hole:>9} {row.broken:>6} {prov}"
|
|
357
|
+
)
|
|
358
|
+
return "\n".join(out)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _cmd_portfolio(args: argparse.Namespace) -> int:
|
|
362
|
+
"""Roll up many repos into one scoreboard — coverage, must-HOLEs, provenance per repo."""
|
|
363
|
+
rows = [_repo_row(path) for path in args.configs]
|
|
364
|
+
if args.json:
|
|
365
|
+
print(json.dumps([asdict(r) for r in rows], indent=2))
|
|
366
|
+
else:
|
|
367
|
+
print(_render_portfolio(rows))
|
|
368
|
+
return 2 if any(r.error is not None for r in rows) else 0
|
|
369
|
+
|
|
370
|
+
|
|
304
371
|
def _git_rev(root: Path) -> str | None:
|
|
305
372
|
head = root / ".git" / "HEAD"
|
|
306
373
|
try:
|
|
@@ -353,6 +420,11 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
353
420
|
sh.add_argument("--json", action="store_true", help="machine-readable output")
|
|
354
421
|
sh.set_defaults(func=_cmd_show)
|
|
355
422
|
|
|
423
|
+
pf = sub.add_parser("portfolio", help="roll up many repos into one scoreboard")
|
|
424
|
+
pf.add_argument("configs", nargs="+", metavar="PRODC_TOML", help="one or more prodc.toml paths")
|
|
425
|
+
pf.add_argument("--json", action="store_true", help="machine-readable output")
|
|
426
|
+
pf.set_defaults(func=_cmd_portfolio)
|
|
427
|
+
|
|
356
428
|
args = parser.parse_args(argv)
|
|
357
429
|
return int(args.func(args))
|
|
358
430
|
|
|
@@ -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
|
|
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
|
-
|
|
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__(
|
|
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 =
|
|
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(
|
|
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
|
|
@@ -54,8 +54,8 @@ Feature: orders
|
|
|
54
54
|
|
|
55
55
|
|
|
56
56
|
def _project(tmp_path: Path) -> Path:
|
|
57
|
-
(tmp_path / "needs").mkdir()
|
|
58
|
-
(tmp_path / "features").mkdir()
|
|
57
|
+
(tmp_path / "needs").mkdir(parents=True, exist_ok=True)
|
|
58
|
+
(tmp_path / "features").mkdir(parents=True, exist_ok=True)
|
|
59
59
|
(tmp_path / "prodc.toml").write_text(_TOML, encoding="utf-8")
|
|
60
60
|
(tmp_path / "needs" / "a.yaml").write_text(_NEEDS, encoding="utf-8")
|
|
61
61
|
(tmp_path / "features" / "orders.feature").write_text(_FEATURE, encoding="utf-8")
|
|
@@ -150,3 +150,30 @@ def test_show_unknown_need_exits_2(tmp_path: Path, capsys: pytest.CaptureFixture
|
|
|
150
150
|
code = main(["show", "NOPE", "--config", str(_project(tmp_path))])
|
|
151
151
|
capsys.readouterr()
|
|
152
152
|
assert code == 2
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def test_portfolio_rolls_up_many_repos(
|
|
156
|
+
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
157
|
+
) -> None:
|
|
158
|
+
a = _project(tmp_path / "alpha")
|
|
159
|
+
b = _project(tmp_path / "beta")
|
|
160
|
+
code = main(["portfolio", str(a), str(b)])
|
|
161
|
+
out = capsys.readouterr().out
|
|
162
|
+
assert code == 0
|
|
163
|
+
assert "alpha" in out and "beta" in out
|
|
164
|
+
assert "33%" in out # each repo is 1/3 delivered
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_portfolio_json(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
|
168
|
+
import json
|
|
169
|
+
|
|
170
|
+
main(["portfolio", str(_project(tmp_path / "alpha")), "--json"])
|
|
171
|
+
rows = json.loads(capsys.readouterr().out)
|
|
172
|
+
assert rows[0]["repo"] == "alpha" and rows[0]["total"] == 3
|
|
173
|
+
assert rows[0]["provenance"]["interview"] == 1
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def test_portfolio_bad_config_exits_2(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
|
177
|
+
code = main(["portfolio", str(tmp_path / "nope.toml")])
|
|
178
|
+
capsys.readouterr()
|
|
179
|
+
assert code == 2
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|