prodc 0.4.3__tar.gz → 0.4.4__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.4.3 → prodc-0.4.4}/PKG-INFO +1 -1
- {prodc-0.4.3 → prodc-0.4.4}/pyproject.toml +1 -1
- {prodc-0.4.3 → prodc-0.4.4}/src/prodc/cli/app.py +7 -0
- {prodc-0.4.3 → prodc-0.4.4}/src/prodc/cli/commands.py +1 -0
- {prodc-0.4.3 → prodc-0.4.4}/src/prodc/cli/render.py +3 -2
- {prodc-0.4.3 → prodc-0.4.4}/src/prodc/config.py +11 -0
- {prodc-0.4.3 → prodc-0.4.4}/src/prodc/loader.py +13 -0
- {prodc-0.4.3 → prodc-0.4.4}/src/prodc/model.py +8 -1
- {prodc-0.4.3 → prodc-0.4.4}/src/prodc/report.py +5 -5
- {prodc-0.4.3 → prodc-0.4.4}/src/prodc/writer.py +5 -1
- {prodc-0.4.3 → prodc-0.4.4}/tests/test_principles.py +48 -1
- {prodc-0.4.3 → prodc-0.4.4}/.gitignore +0 -0
- {prodc-0.4.3 → prodc-0.4.4}/CHANGELOG.md +0 -0
- {prodc-0.4.3 → prodc-0.4.4}/LICENSE +0 -0
- {prodc-0.4.3 → prodc-0.4.4}/README.md +0 -0
- {prodc-0.4.3 → prodc-0.4.4}/examples/quickstart/features/orders.feature +0 -0
- {prodc-0.4.3 → prodc-0.4.4}/examples/quickstart/needs/jordan.yaml +0 -0
- {prodc-0.4.3 → prodc-0.4.4}/examples/quickstart/prodc.toml +0 -0
- {prodc-0.4.3 → prodc-0.4.4}/src/prodc/__init__.py +0 -0
- {prodc-0.4.3 → prodc-0.4.4}/src/prodc/adapters.py +0 -0
- {prodc-0.4.3 → prodc-0.4.4}/src/prodc/agent.md +0 -0
- {prodc-0.4.3 → prodc-0.4.4}/src/prodc/board.py +0 -0
- {prodc-0.4.3 → prodc-0.4.4}/src/prodc/callbacks.py +0 -0
- {prodc-0.4.3 → prodc-0.4.4}/src/prodc/cli/__init__.py +0 -0
- {prodc-0.4.3 → prodc-0.4.4}/src/prodc/cli/common.py +0 -0
- {prodc-0.4.3 → prodc-0.4.4}/src/prodc/cli/serialize.py +0 -0
- {prodc-0.4.3 → prodc-0.4.4}/src/prodc/dedup.py +0 -0
- {prodc-0.4.3 → prodc-0.4.4}/src/prodc/guide.md +0 -0
- {prodc-0.4.3 → prodc-0.4.4}/src/prodc/py.typed +0 -0
- {prodc-0.4.3 → prodc-0.4.4}/src/prodc/report_html.py +0 -0
- {prodc-0.4.3 → prodc-0.4.4}/src/prodc/status.py +0 -0
- {prodc-0.4.3 → prodc-0.4.4}/tests/test_adapters.py +0 -0
- {prodc-0.4.3 → prodc-0.4.4}/tests/test_answer.py +0 -0
- {prodc-0.4.3 → prodc-0.4.4}/tests/test_callbacks.py +0 -0
- {prodc-0.4.3 → prodc-0.4.4}/tests/test_citation.py +0 -0
- {prodc-0.4.3 → prodc-0.4.4}/tests/test_claim_gating.py +0 -0
- {prodc-0.4.3 → prodc-0.4.4}/tests/test_cli.py +0 -0
- {prodc-0.4.3 → prodc-0.4.4}/tests/test_command_hook.py +0 -0
- {prodc-0.4.3 → prodc-0.4.4}/tests/test_contradicts.py +0 -0
- {prodc-0.4.3 → prodc-0.4.4}/tests/test_decisions.py +0 -0
- {prodc-0.4.3 → prodc-0.4.4}/tests/test_dedup.py +0 -0
- {prodc-0.4.3 → prodc-0.4.4}/tests/test_description.py +0 -0
- {prodc-0.4.3 → prodc-0.4.4}/tests/test_drafts.py +0 -0
- {prodc-0.4.3 → prodc-0.4.4}/tests/test_evalgate.py +0 -0
- {prodc-0.4.3 → prodc-0.4.4}/tests/test_external_adapter.py +0 -0
- {prodc-0.4.3 → prodc-0.4.4}/tests/test_gitlab_source.py +0 -0
- {prodc-0.4.3 → prodc-0.4.4}/tests/test_importance.py +0 -0
- {prodc-0.4.3 → prodc-0.4.4}/tests/test_loader.py +0 -0
- {prodc-0.4.3 → prodc-0.4.4}/tests/test_models.py +0 -0
- {prodc-0.4.3 → prodc-0.4.4}/tests/test_needref.py +0 -0
- {prodc-0.4.3 → prodc-0.4.4}/tests/test_relink.py +0 -0
- {prodc-0.4.3 → prodc-0.4.4}/tests/test_report.py +0 -0
- {prodc-0.4.3 → prodc-0.4.4}/tests/test_report_html.py +0 -0
- {prodc-0.4.3 → prodc-0.4.4}/tests/test_review.py +0 -0
- {prodc-0.4.3 → prodc-0.4.4}/tests/test_roles.py +0 -0
- {prodc-0.4.3 → prodc-0.4.4}/tests/test_source_role.py +0 -0
- {prodc-0.4.3 → prodc-0.4.4}/tests/test_sources.py +0 -0
- {prodc-0.4.3 → prodc-0.4.4}/tests/test_staleness.py +0 -0
- {prodc-0.4.3 → prodc-0.4.4}/tests/test_status.py +0 -0
- {prodc-0.4.3 → prodc-0.4.4}/tests/test_suggest.py +0 -0
- {prodc-0.4.3 → prodc-0.4.4}/tests/test_variants.py +0 -0
- {prodc-0.4.3 → prodc-0.4.4}/tests/test_who_variants.py +0 -0
- {prodc-0.4.3 → prodc-0.4.4}/tests/test_withdrawn.py +0 -0
- {prodc-0.4.3 → prodc-0.4.4}/tests/test_writer.py +0 -0
- {prodc-0.4.3 → prodc-0.4.4}/tests/test_writer_comments.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: prodc
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.4
|
|
4
4
|
Summary: Product management as code: roles, 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
|
|
@@ -181,6 +181,13 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
181
181
|
help="principle only: promote a need into a principle — copy its text (unless --text "
|
|
182
182
|
"overrides) and its sources as the provenance that surfaced it; the need stays on the board",
|
|
183
183
|
)
|
|
184
|
+
ad.add_argument(
|
|
185
|
+
"--scope",
|
|
186
|
+
default="product",
|
|
187
|
+
metavar="SCOPE",
|
|
188
|
+
help="principle only: its scope — product (default), design, visual, engineering; "
|
|
189
|
+
"must be declared in [principles].scopes",
|
|
190
|
+
)
|
|
184
191
|
ad.add_argument(
|
|
185
192
|
"--needs",
|
|
186
193
|
default=None,
|
|
@@ -285,11 +285,12 @@ def render_principles(reports: list[PrincipleReport]) -> str:
|
|
|
285
285
|
An principle is held as given, not validated; ``declined`` are the needs withdrawn citing it, and
|
|
286
286
|
``stale`` (the ripple) are those decided before its latest ``revised_at`` — flagged to re-review.
|
|
287
287
|
"""
|
|
288
|
-
out = ["\nprinciples (the
|
|
288
|
+
out = ["\nprinciples (the vision — held as given):"]
|
|
289
289
|
for rep in reports:
|
|
290
290
|
a = rep.principle
|
|
291
291
|
rev = f"revised {a.revised_at}" if a.revised_at else "undated"
|
|
292
|
-
|
|
292
|
+
scope = "" if a.scope == "product" else f"[{a.scope}] "
|
|
293
|
+
out.append(f" {a.id:<6} {scope}{rev} {a.text}")
|
|
293
294
|
if rep.declined:
|
|
294
295
|
out.append(f" declined: {', '.join(n.id for n in rep.declined)}")
|
|
295
296
|
if rep.stale:
|
|
@@ -119,6 +119,7 @@ class Config:
|
|
|
119
119
|
default_draft: bool = False # [add].default_draft — `add` stages as draft by default (#60)
|
|
120
120
|
callbacks: tuple[Callback, ...] = () # [[callbacks.hooks]] — lifecycle callbacks (#71)
|
|
121
121
|
callback_state_file: str = ".prodc/state.json" # [callbacks].state_file — the state ledger
|
|
122
|
+
principle_scopes: tuple[str, ...] = ("product",) # [principles].scopes — allowed scopes (#88)
|
|
122
123
|
|
|
123
124
|
def importance_label(self, level: int | None) -> str | None:
|
|
124
125
|
"""The declared label for an importance level, or None if unset/undeclared (#59)."""
|
|
@@ -302,6 +303,15 @@ class Config:
|
|
|
302
303
|
|
|
303
304
|
callbacks, callback_state_file = _callbacks(raw)
|
|
304
305
|
|
|
306
|
+
principles_tbl = _as_dict(raw.get("principles", {}), "[principles]")
|
|
307
|
+
if "scopes" in principles_tbl:
|
|
308
|
+
scopes = _as_str_list(principles_tbl["scopes"], "[principles].scopes")
|
|
309
|
+
if not scopes:
|
|
310
|
+
raise ConfigError("[principles].scopes must list at least one scope")
|
|
311
|
+
principle_scopes = tuple(scopes)
|
|
312
|
+
else:
|
|
313
|
+
principle_scopes = ("product",)
|
|
314
|
+
|
|
305
315
|
return cls(
|
|
306
316
|
root=root,
|
|
307
317
|
need_globs=need_globs,
|
|
@@ -315,4 +325,5 @@ class Config:
|
|
|
315
325
|
default_draft=default_draft,
|
|
316
326
|
callbacks=callbacks,
|
|
317
327
|
callback_state_file=callback_state_file,
|
|
328
|
+
principle_scopes=principle_scopes,
|
|
318
329
|
)
|
|
@@ -349,6 +349,7 @@ def _principle(raw: object, where: str) -> Principle:
|
|
|
349
349
|
return Principle(
|
|
350
350
|
id=_as_str(tbl.get("id"), f"{where}.id"),
|
|
351
351
|
text=_as_str(tbl.get("text"), f"{where}.text"),
|
|
352
|
+
scope=_opt_str(tbl.get("scope"), f"{where}.scope") or "product",
|
|
352
353
|
revised_at=_opt_str(tbl.get("revised_at"), f"{where}.revised_at"),
|
|
353
354
|
sources=tuple(
|
|
354
355
|
_source(s, f"{where}.sources[{i}]")
|
|
@@ -534,6 +535,7 @@ def load(config: Config) -> Model:
|
|
|
534
535
|
_check_withdrawal_of(acc.needs)
|
|
535
536
|
_check_source_roles(acc.needs, acc.principles, {p.id for p in acc.roles})
|
|
536
537
|
_check_decisions(acc.decisions, {n.id for n in acc.needs}, {p.id for p in acc.principles})
|
|
538
|
+
_check_principle_scopes(acc.principles, config.principle_scopes)
|
|
537
539
|
acc.needs = _symmetrize_contradicts(acc.needs)
|
|
538
540
|
return Model(
|
|
539
541
|
needs=tuple(acc.needs),
|
|
@@ -587,6 +589,17 @@ def _check_children(needs: list[Need]) -> None:
|
|
|
587
589
|
raise LoaderError(f"need {need.id!r}: child {c.id!r} is not a known need id")
|
|
588
590
|
|
|
589
591
|
|
|
592
|
+
def _check_principle_scopes(principles: list[Principle], allowed: tuple[str, ...]) -> None:
|
|
593
|
+
"""Every principle's ``scope`` must be one declared in ``[principles].scopes`` (#88)."""
|
|
594
|
+
allowed_set = set(allowed)
|
|
595
|
+
for p in principles:
|
|
596
|
+
if p.scope not in allowed_set:
|
|
597
|
+
raise LoaderError(
|
|
598
|
+
f"principle {p.id!r}: scope {p.scope!r} is not declared in "
|
|
599
|
+
f"[principles].scopes ({', '.join(allowed)})"
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
|
|
590
603
|
def _check_withdrawal_of(needs: list[Need]) -> None:
|
|
591
604
|
"""A withdrawal's ``of`` must name a known need (never itself); required when kind is duplicate.
|
|
592
605
|
|
|
@@ -162,11 +162,18 @@ class Principle:
|
|
|
162
162
|
client), and ``revised_at`` dates its latest statement. A decision taken under an older version
|
|
163
163
|
of the principle is flagged for re-review when the principle is revised — the ripple. A separate noun
|
|
164
164
|
(like :class:`Role`), not a Need marker: a principle is a stance, not what a person wants, so it
|
|
165
|
-
never enters coverage, the plan, or the opinion check.
|
|
165
|
+
never enters coverage, the plan, or the opinion check — in every ``scope`` (#88).
|
|
166
|
+
|
|
167
|
+
``scope`` says which kind of commitment it is — product, design, visual, engineering — so design,
|
|
168
|
+
visual and engineering principles live on the board beside product ones instead of in prose docs
|
|
169
|
+
and code comments, where a Decision or an ``out_of_scope`` withdrawal cannot cite them. The scope
|
|
170
|
+
must be one declared in ``[principles].scopes`` (default ``["product"]``); an undeclared scope is a
|
|
171
|
+
load error the author owns.
|
|
166
172
|
"""
|
|
167
173
|
|
|
168
174
|
id: str
|
|
169
175
|
text: str
|
|
176
|
+
scope: str = "product" # product/design/visual/engineering — declared in [principles].scopes (#88)
|
|
170
177
|
revised_at: str | None = None # ISO date the principle was last stated or revised
|
|
171
178
|
sources: tuple[Source, ...] = () # the signals that surfaced it — provenance, not validation
|
|
172
179
|
|
|
@@ -154,18 +154,18 @@ def render_markdown(
|
|
|
154
154
|
|
|
155
155
|
if model.principles:
|
|
156
156
|
out += [
|
|
157
|
-
"## Principles (the
|
|
157
|
+
"## Principles (the vision — held as given)",
|
|
158
158
|
"",
|
|
159
|
-
"| principle | stated | needs declined under it |",
|
|
160
|
-
"
|
|
159
|
+
"| principle | scope | stated | needs declined under it |",
|
|
160
|
+
"|---|---|---|---|",
|
|
161
161
|
]
|
|
162
162
|
for rep in principle_report(list(model.needs), list(model.principles)):
|
|
163
163
|
text = rep.principle.text.replace("|", "\\|")
|
|
164
164
|
declined = ", ".join(n.id for n in rep.declined) or "—"
|
|
165
165
|
stale = f" _(re-review {', '.join(n.id for n in rep.stale)})_" if rep.stale else ""
|
|
166
166
|
out.append(
|
|
167
|
-
f"| {rep.principle.id}: {text} | {rep.principle.
|
|
168
|
-
f"| {declined}{stale} |"
|
|
167
|
+
f"| {rep.principle.id}: {text} | {rep.principle.scope} "
|
|
168
|
+
f"| {rep.principle.revised_at or '—'} | {declined}{stale} |"
|
|
169
169
|
)
|
|
170
170
|
out.append("")
|
|
171
171
|
|
|
@@ -914,6 +914,7 @@ def add_principle(
|
|
|
914
914
|
*,
|
|
915
915
|
file: str,
|
|
916
916
|
revised_at: str,
|
|
917
|
+
scope: str = "product",
|
|
917
918
|
sources: tuple[Source, ...] = (),
|
|
918
919
|
expect_sha: str | None = None,
|
|
919
920
|
dry_run: bool,
|
|
@@ -943,7 +944,10 @@ def add_principle(
|
|
|
943
944
|
for a in principles
|
|
944
945
|
):
|
|
945
946
|
raise WriteError(f"principle {principle_id!r} already exists in {target.name}")
|
|
946
|
-
entry: dict[str, object] = {"id": principle_id, "text": text
|
|
947
|
+
entry: dict[str, object] = {"id": principle_id, "text": text}
|
|
948
|
+
if scope != "product":
|
|
949
|
+
entry["scope"] = scope # written only when non-default, to keep the common case clean
|
|
950
|
+
entry["revised_at"] = revised_at
|
|
947
951
|
if sources:
|
|
948
952
|
entry["sources"] = [_source_dict(s) for s in sources]
|
|
949
953
|
principles.append(entry)
|
|
@@ -132,6 +132,53 @@ principles:
|
|
|
132
132
|
load(Config.load(_project(tmp_path, needs=needs)))
|
|
133
133
|
|
|
134
134
|
|
|
135
|
+
# --- scope (#88) ------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
_SCOPED_TOML = """
|
|
138
|
+
[prodc]
|
|
139
|
+
needs = ["needs/*.yaml"]
|
|
140
|
+
|
|
141
|
+
[principles]
|
|
142
|
+
scopes = ["product", "visual", "engineering"]
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_principle_scope_defaults_to_product(tmp_path: Path) -> None:
|
|
147
|
+
needs = "principles:\n - id: P-1\n text: a principle\n"
|
|
148
|
+
model = load(Config.load(_project(tmp_path, needs=needs)))
|
|
149
|
+
assert model.principles[0].scope == "product"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_declared_scope_loads(tmp_path: Path) -> None:
|
|
153
|
+
needs = "principles:\n - id: P-1\n text: colour is load-bearing\n scope: visual\n"
|
|
154
|
+
model = load(Config.load(_project(tmp_path, toml=_SCOPED_TOML, needs=needs)))
|
|
155
|
+
assert model.principles[0].scope == "visual"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_undeclared_scope_rejected(tmp_path: Path) -> None:
|
|
159
|
+
# default [principles].scopes is just ["product"], so "visual" is undeclared here.
|
|
160
|
+
needs = "principles:\n - id: P-1\n text: a principle\n scope: visual\n"
|
|
161
|
+
with pytest.raises(LoaderError, match="scope 'visual' is not declared"):
|
|
162
|
+
load(Config.load(_project(tmp_path, needs=needs)))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def test_add_principle_writes_scope(tmp_path: Path) -> None:
|
|
166
|
+
from prodc.writer import add_principle
|
|
167
|
+
|
|
168
|
+
cfg = Config.load(_project(tmp_path, toml=_SCOPED_TOML, needs="principles:\n - id: P-0\n text: seed\n"))
|
|
169
|
+
add_principle(
|
|
170
|
+
cfg,
|
|
171
|
+
"P-9",
|
|
172
|
+
"colour is load-bearing",
|
|
173
|
+
file="needs/a.yaml",
|
|
174
|
+
revised_at=date.today().isoformat(),
|
|
175
|
+
scope="visual",
|
|
176
|
+
dry_run=False,
|
|
177
|
+
)
|
|
178
|
+
model = load(cfg)
|
|
179
|
+
assert next(p for p in model.principles if p.id == "P-9").scope == "visual"
|
|
180
|
+
|
|
181
|
+
|
|
135
182
|
# --- principle_report (declined / stale) ------------------------------------
|
|
136
183
|
|
|
137
184
|
|
|
@@ -275,7 +322,7 @@ def test_status_text_shows_principles_section(
|
|
|
275
322
|
|
|
276
323
|
main(["status", "--config", str(_project(tmp_path))])
|
|
277
324
|
out = capsys.readouterr().out
|
|
278
|
-
assert "principles (the
|
|
325
|
+
assert "principles (the vision — held as given):" in out
|
|
279
326
|
assert "P-1" in out and "revised 2026-10-15" in out
|
|
280
327
|
assert "declined: N-7, N-9" in out
|
|
281
328
|
assert "decided before the latest revision: N-7 — re-review" in out
|
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|