design-playbook 0.20.0 → 0.20.2
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.
- package/examples/dogfood/run/shaping/shaping-log.jsonl +1 -1
- package/examples/export-entry/run/evidence/L6.1-usertest-notes.md +8 -13
- package/examples/export-entry/run/evidence/manifest.jsonl +0 -1
- package/examples/export-entry/run/point-back.md +2 -2
- package/examples/export-entry/run/shaping/shaping-log.jsonl +2 -2
- package/examples/export-pointfix-upgrade/run/shaping/shaping-log.jsonl +1 -1
- package/examples/export-upgrade/run/shaping/shaping-log.jsonl +1 -1
- package/mcp/evidence/capture_runtime.py +6 -7
- package/mcp/evidence/test_server_stdio.py +28 -0
- package/package.json +1 -1
- package/scripts/dd_entries.py +146 -8
- package/scripts/escalation_signals.py +9 -1
- package/scripts/g10_design_decisions.py +74 -2
- package/scripts/g8_run_registry.py +14 -4
- package/scripts/learning_candidates.py +5 -5
- package/scripts/run_facts.py +113 -1
- package/scripts/run_profile.py +5 -0
- package/scripts/run_status.py +47 -69
- package/scripts/shaping_log.py +29 -20
- package/scripts/validate_run.py +46 -57
- package/skills/craft-guard/SKILL.md +2 -0
- package/skills/design-playbook/SKILL.md +2 -0
- package/skills/reference-intake/SKILL.md +2 -0
- package/skills/ui-evaluator/SKILL.md +6 -2
- package/skills/ui-picker/SKILL.md +2 -0
- package/skills/ui-picker/references/decisions.md +2 -2
- package/skills/ux-spec/SKILL.md +2 -0
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
{"event": "answered", "question_id": "Q1", "answer": "运行状态跨页可查、全局控制可用、失败可批量治理", "ts": "2026-08-14T09:02:00Z"}
|
|
3
3
|
{"event": "asked", "question_id": "Q9", "batch": 2, "tier": "T3", "text": "顶栏计数区升级为全局运行控制台——视觉方向(构成级)?", "impact": "D3 decision-report(成形只登记路由,D3 裁决)", "ts": "2026-08-14T09:03:00Z"}
|
|
4
4
|
{"event": "assumption_staged", "field": "sim.control_scope", "tier": "T2", "reason": "全局暂停的作用范围未答", "risk": "误伤手动单次重试", "fallback": "全局暂停/恢复不作用于手动单次重试", "ts": "2026-08-14T09:04:00Z"}
|
|
5
|
-
{"event": "confirm_presented", "batch": "CP-A", "kind": "intent", "items": ["l1.goal 修订(supersedes D-0001)", "l6.c4
|
|
5
|
+
{"event": "confirm_presented", "batch": "CP-A", "kind": "intent", "items": [{"field": "l1.goal", "value": "修订(supersedes D-0001)"}, "l6.c4", "l6.c5", "l6.c6"], "ts": "2026-08-14T09:08:00Z"}
|
|
6
6
|
{"event": "confirm_presented", "batch": "CP-C", "kind": "assumption", "items": ["sim.control_scope"], "ts": "2026-08-14T09:08:00Z"}
|
|
7
7
|
{"event": "item_confirmed", "batch": "CP-A", "field": "l1.goal", "ts": "2026-08-14T09:12:00Z"}
|
|
8
8
|
{"event": "item_confirmed", "batch": "CP-A", "field": "l6.c4", "ts": "2026-08-14T09:12:00Z"}
|
|
@@ -1,15 +1,10 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Synthetic user-test example — not evidence
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
Schema/example material only. No real human-subject session occurred, no
|
|
4
|
+
participant observations or personal data are represented, and this file is
|
|
5
|
+
not registered in `manifest.jsonl`.
|
|
5
6
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
anonymisation / retention record was completed, so the manifest entry
|
|
11
|
-
carries `method: user-test` with `population` but no `ethics` key. Per the
|
|
12
|
-
method-semantics gate this entry is quarantined as blocked evidence: it
|
|
13
|
-
cannot support any judgment (the L6.1 pass rests on
|
|
14
|
-
`L6.1-export-trace.json`, not on these notes). Complete the ethics record
|
|
15
|
-
in a newer manifest entry to make the session citable.
|
|
7
|
+
The scenario illustrates what a user-test note might look like during method
|
|
8
|
+
semantics testing. It must not support a run judgment or any claim about user
|
|
9
|
+
behavior. Tests that exercise missing `ethics` construct their own temporary
|
|
10
|
+
manifest entry rather than treating this file as evidence.
|
|
@@ -5,4 +5,3 @@
|
|
|
5
5
|
{"criterion": "L6.2", "capture": {"type": "a11y tree", "provider": "manual", "state": "cap-blocked-r2", "actions": ["select over-cap range", "open export dialog (post R4 fix)"], "schemaVersion": 1, "request": {"schemaVersion": 1, "viewport": {"width": 1280, "height": 800, "devicePixelRatio": 1.0, "colorScheme": "light"}, "freeze": {"enabled": true, "waitFonts": true, "networkIdle": false}}}, "artifact": "L6.2-a11y-tree-r2.json", "observed_state": "cap-blocked-r2", "result": "captured", "ts": "2026-08-14T11:04:30Z", "request": {"schemaVersion": 1, "viewport": {"width": 1280, "height": 800, "devicePixelRatio": 1.0, "colorScheme": "light"}, "freeze": {"enabled": true, "waitFonts": true, "networkIdle": false}}, "method": "runtime-observation", "observation": "toast 节点带 role=alert 与可读名称(含超限数值 200,000)", "interpretation": "R4 修复后的 a11y 附接证据,印证 L6.2 pass(ADR-0016 附接到既有 user-risk 判据)", "scope": "单次运行, viewport 1280x800, 数据集 week-2026-32", "population": null, "ethics": null}
|
|
6
6
|
{"criterion": "L6.2", "capture": {"type": "screenshot", "provider": "manual", "state": "cap-blocked-r2", "actions": ["select over-cap range", "open export dialog (post R4 fix)"], "schemaVersion": 1, "request": {"schemaVersion": 1, "viewport": {"width": 1280, "height": 800, "devicePixelRatio": 1.0, "colorScheme": "light"}, "freeze": {"enabled": true, "waitFonts": true, "networkIdle": false}}}, "artifact": "L6.2-cap-error-r2.png", "observed_state": "cap-blocked-r2", "result": "captured", "ts": "2026-08-14T11:05:00Z", "request": {"schemaVersion": 1, "viewport": {"width": 1280, "height": 800, "devicePixelRatio": 1.0, "colorScheme": "light"}, "freeze": {"enabled": true, "waitFonts": true, "networkIdle": false}}, "method": "runtime-observation", "observation": "提示含「超出 200,000 行上限」与「按周导出」收窄建议(重评 r2)", "interpretation": "满足 c2 的 Then 子句(剩余量与收窄建议);依赖 assumed 字段 export.row_cap 成立", "scope": "单次运行, viewport 1280x800, 数据集 week-2026-32", "population": null, "ethics": null}
|
|
7
7
|
{"criterion": "L6.3", "capture": {"type": "interaction trace", "provider": "manual", "state": "return-visible", "actions": ["start export", "navigate away", "navigate back", "read progress and result"], "schemaVersion": 1, "request": {"schemaVersion": 1, "viewport": {"width": 1280, "height": 800, "devicePixelRatio": 1.0, "colorScheme": "light"}, "freeze": {"enabled": true, "waitFonts": true, "networkIdle": false}}}, "artifact": "L6.3-return-trace.json", "observed_state": "return-visible", "result": "captured", "ts": "2026-08-14T11:10:00Z", "request": {"schemaVersion": 1, "viewport": {"width": 1280, "height": 800, "devicePixelRatio": 1.0, "colorScheme": "light"}, "freeze": {"enabled": true, "waitFonts": true, "networkIdle": false}}, "method": "runtime-observation", "observation": "导出中离开 main-list 后返回,exporting 进度态可见,完成后结果可获知(R5 修订 capture plan 后重采)", "interpretation": "满足 c3 的 Then 子句(进度与结果仍可获知);首轮跨导航 session 丢失记 blocked 已按 invalidated 块登记", "scope": "单次运行, viewport 1280x800, 数据集 week-2026-32", "population": null, "ethics": null}
|
|
8
|
-
{"criterion": "L6.1", "capture": {"type": "session notes", "provider": "manual", "state": "usertest-round1", "actions": ["facilitate weekly-report export task", "record participant behavior"], "schemaVersion": 1, "request": {"schemaVersion": 1, "viewport": {"width": 1280, "height": 800, "devicePixelRatio": 1.0, "colorScheme": "light"}, "freeze": {"enabled": true, "waitFonts": true, "networkIdle": false}}}, "artifact": "L6.1-usertest-notes.md", "observed_state": "usertest-round1", "result": "captured", "ts": "2026-08-14T10:50:00Z", "request": {"schemaVersion": 1, "viewport": {"width": 1280, "height": 800, "devicePixelRatio": 1.0, "colorScheme": "light"}, "freeze": {"enabled": true, "waitFonts": true, "networkIdle": false}}, "method": "user-test", "observation": "3 名运营参与者完成周报导出任务;其中 2 人先在全局工具栏寻找导出入口,经提示后使用行内入口", "interpretation": null, "scope": "单次会话, 3 名运营参与者, 便利抽样", "population": "3 名运营角色参与者(周报任务,便利抽样)", "ethics": null}
|
|
@@ -67,7 +67,7 @@ face: subjective
|
|
|
67
67
|
basis: agent-judgment
|
|
68
68
|
confidence: low
|
|
69
69
|
disposition: advisory
|
|
70
|
-
evidence: rendered 走查(agent-judgment, method=expert-review
|
|
70
|
+
evidence: rendered 走查(agent-judgment, method=expert-review);非用户证据——evidence/L6.1-usertest-notes.md 仅为合成 schema 示例,未登记到 Manifest,不能支持判断
|
|
71
71
|
```
|
|
72
72
|
|
|
73
73
|
## Positive findings
|
|
@@ -107,7 +107,7 @@ evidence: evidence/L6.1-export-trace.json(跨层:交互轨迹 + 度量计
|
|
|
107
107
|
## Limitations statement
|
|
108
108
|
|
|
109
109
|
- 判断类 advisory:术语适配(task-organization 主观面,agent-judgment 非用户证据,confidence=low,advisory 不阻 verdict)
|
|
110
|
-
- 用户代表性:本 run
|
|
110
|
+
- 用户代表性:本 run 无真实 user-test evidence;合成 notes 未登记到 Manifest,全部结论不构成任何「用户会」断言
|
|
111
111
|
- pass 范围:L6.1/L6.2/L6.3 pass 限单 viewport 1280x800 / 单数据集 week-2026-32 / 单次运行
|
|
112
112
|
- assumed 依赖:L6.2 pass 依赖 export.row_cap 假设成立
|
|
113
113
|
- 机器面证明声明与事实一致,不证明体验良好
|
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
{"event": "assumption_staged", "field": "export.row_cap", "tier": "T1", "reason": "规模上限未答(队列满,转显式风险确认)", "risk": "上限过低阻断真实导出或过高拖垮同步窗口", "fallback": "50000 行", "ts": "2026-08-14T09:34:00Z"}
|
|
9
9
|
{"event": "assumption_staged", "field": "export.sync_window", "tier": "T2", "reason": "同步导出限时未逐字确认", "risk": "超时体验未定义", "fallback": "60 秒", "ts": "2026-08-14T09:34:00Z"}
|
|
10
10
|
{"event": "assumption_staged", "field": "export.column_scope", "tier": "T4", "reason": "文件命名与列范围为局部实现选择", "risk": "隐藏列误出", "fallback": "当前视图列(不含隐藏列)", "ts": "2026-08-14T09:34:00Z"}
|
|
11
|
-
{"event": "confirm_presented", "batch": "CP-A", "kind": "intent", "items": ["l1.goal", "l1.target_user", "l6.c1
|
|
12
|
-
{"event": "confirm_presented", "batch": "CP-B", "kind": "structural", "items": ["入口 IA 两案:A 全局工具栏 / B 主列表行内批量"], "ts": "2026-08-14T09:40:00Z"}
|
|
11
|
+
{"event": "confirm_presented", "batch": "CP-A", "kind": "intent", "items": ["l1.goal", "l1.target_user", "l6.c1", "l6.c2", "l6.c3", "l1.non_goals"], "ts": "2026-08-14T09:40:00Z"}
|
|
12
|
+
{"event": "confirm_presented", "batch": "CP-B", "kind": "structural", "items": [{"field": "l2.entry_choice", "value": "入口 IA 两案:A 全局工具栏 / B 主列表行内批量"}], "ts": "2026-08-14T09:40:00Z"}
|
|
13
13
|
{"event": "confirm_presented", "batch": "CP-C", "kind": "assumption", "items": ["export.row_cap", "export.sync_window", "export.column_scope", "l1.scenes"], "ts": "2026-08-14T09:40:00Z"}
|
|
14
14
|
{"event": "item_confirmed", "batch": "CP-A", "field": "l1.goal", "ts": "2026-08-14T09:52:00Z"}
|
|
15
15
|
{"event": "item_confirmed", "batch": "CP-A", "field": "l1.target_user", "ts": "2026-08-14T09:52:00Z"}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{"event": "asked", "question_id": "Q1", "batch": 1, "tier": "T1", "text": "「只导出选中列」的完成判据如何表述?", "impact": "l6.c4", "ts": "2026-08-14T12:41:00Z"}
|
|
2
2
|
{"event": "answered", "question_id": "Q1", "answer": "Given 运营在主列表圈选 2 列 When 触发行内导出 Then CSV 仅含圈选列且顺序与列表一致(证据:交互记录)", "ts": "2026-08-14T12:42:00Z"}
|
|
3
|
-
{"event": "confirm_presented", "batch": "CP-A", "kind": "intent", "items": ["l6.c4
|
|
3
|
+
{"event": "confirm_presented", "batch": "CP-A", "kind": "intent", "items": ["l6.c4"], "ts": "2026-08-14T12:43:00Z"}
|
|
4
4
|
{"event": "item_confirmed", "batch": "CP-A", "field": "l6.c4", "ts": "2026-08-14T12:44:00Z"}
|
|
5
5
|
{"event": "projected", "ts": "2026-08-14T12:45:00Z", "order": ["promote_fields", "append_decision", "apply_decisions", "spec", "bind_first"], "decisions": ["D-0101"], "assumed": [], "mappings": [{"decision": "D-0101", "field": "l6.c4", "spec_section": "L6"}]}
|
|
6
6
|
{"event": "archived", "ts": "2026-08-14T12:46:00Z"}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
{"event": "answered", "question_id": "Q1", "answer": "任务全程可见、跨页保持、结果可获知", "ts": "2026-08-14T10:44:00Z"}
|
|
3
3
|
{"event": "asked", "question_id": "Q9", "batch": 2, "tier": "T3", "text": "导出进行中的状态呈现——视觉方向(构成级)?", "impact": "D3 decision-report(成形只登记路由,D3 裁决)", "ts": "2026-08-14T10:46:00Z"}
|
|
4
4
|
{"event": "assumption_staged", "field": "export.task_persist_ttl", "tier": "T2", "reason": "状态条目保留时长未答", "risk": "条目堆积或过早消失", "fallback": "run 内持久(≤10 条)", "ts": "2026-08-14T10:47:00Z"}
|
|
5
|
-
{"event": "confirm_presented", "batch": "CP-A", "kind": "intent", "items": ["l1.goal 修订(supersedes D-0001)", "l6.c4
|
|
5
|
+
{"event": "confirm_presented", "batch": "CP-A", "kind": "intent", "items": [{"field": "l1.goal", "value": "修订(supersedes D-0001)"}, "l6.c4", "l6.c5", "l6.c6"], "ts": "2026-08-14T10:50:00Z"}
|
|
6
6
|
{"event": "confirm_presented", "batch": "CP-C", "kind": "assumption", "items": ["export.task_persist_ttl"], "ts": "2026-08-14T10:50:00Z"}
|
|
7
7
|
{"event": "item_confirmed", "batch": "CP-A", "field": "l1.goal", "ts": "2026-08-14T10:55:00Z"}
|
|
8
8
|
{"event": "item_confirmed", "batch": "CP-A", "field": "l6.c4", "ts": "2026-08-14T10:55:00Z"}
|
|
@@ -256,13 +256,12 @@ def _action_wait_for_state(page: Any, action: dict, index: int, do: str) -> None
|
|
|
256
256
|
if not isinstance(state, str) or not state:
|
|
257
257
|
raise ValueError(f"actions[{index}].state required for wait_for_state")
|
|
258
258
|
selector = action.get("selector")
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
)
|
|
259
|
+
target = (
|
|
260
|
+
f'{selector}[data-state="{state}"]'
|
|
261
|
+
if isinstance(selector, str) and selector
|
|
262
|
+
else f'[data-state="{state}"]'
|
|
263
|
+
)
|
|
264
|
+
page.wait_for_selector(target, timeout=10_000)
|
|
266
265
|
|
|
267
266
|
|
|
268
267
|
def _action_wait(page: Any, action: dict, index: int, do: str) -> None:
|
|
@@ -121,9 +121,37 @@ def _structured(call_response: dict) -> dict:
|
|
|
121
121
|
return json.loads(text)
|
|
122
122
|
|
|
123
123
|
|
|
124
|
+
class _RecordingPage:
|
|
125
|
+
def __init__(self) -> None:
|
|
126
|
+
self.waits: list[tuple[str, int]] = []
|
|
127
|
+
|
|
128
|
+
def wait_for_selector(self, selector: str, *, timeout: int) -> None:
|
|
129
|
+
self.waits.append((selector, timeout))
|
|
130
|
+
|
|
131
|
+
|
|
124
132
|
class EvidencePurePathTests(unittest.TestCase):
|
|
125
133
|
"""No-chromium transport and direct runtime-interface tests."""
|
|
126
134
|
|
|
135
|
+
def test_wait_for_state_combines_selector_and_state(self) -> None:
|
|
136
|
+
page = _RecordingPage()
|
|
137
|
+
capture_runtime._action_wait_for_state(
|
|
138
|
+
page,
|
|
139
|
+
{"do": "wait_for_state", "selector": "body", "state": "ready"},
|
|
140
|
+
0,
|
|
141
|
+
"wait_for_state",
|
|
142
|
+
)
|
|
143
|
+
self.assertEqual(page.waits, [('body[data-state="ready"]', 10_000)])
|
|
144
|
+
|
|
145
|
+
def test_wait_for_state_without_selector_targets_any_matching_state(self) -> None:
|
|
146
|
+
page = _RecordingPage()
|
|
147
|
+
capture_runtime._action_wait_for_state(
|
|
148
|
+
page,
|
|
149
|
+
{"do": "wait_for_state", "state": "ready"},
|
|
150
|
+
0,
|
|
151
|
+
"wait_for_state",
|
|
152
|
+
)
|
|
153
|
+
self.assertEqual(page.waits, [('[data-state="ready"]', 10_000)])
|
|
154
|
+
|
|
127
155
|
def test_parse_capture_contract_requires_schema_and_viewport(self) -> None:
|
|
128
156
|
from design_playbook.mcp.evidence import capture_contract
|
|
129
157
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "design-playbook",
|
|
3
|
-
"version": "0.20.
|
|
3
|
+
"version": "0.20.2",
|
|
4
4
|
"description": "Design I/O for coding agents: controllable UI generation via declarations (spec/domain/craft/design/components/template) and contracts (skill/evaluator). Use for product UI—console, dashboard, agent-ops, CJK-first apps.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
package/scripts/dd_entries.py
CHANGED
|
@@ -43,7 +43,20 @@ stay protocol-side. Gate policy lives in ``g10_design_decisions.py``.
|
|
|
43
43
|
|
|
44
44
|
Flow-map values inside ``- {k: v, ...}`` items may not contain ASCII commas
|
|
45
45
|
or braces (use full-width punctuation in prose values); this is the declared
|
|
46
|
-
shape, same contract style as the seven-column audit rows.
|
|
46
|
+
shape, same contract style as the seven-column audit rows. Items may fold
|
|
47
|
+
across lines (issue #44): a ``- {`` item that does not close its brace on
|
|
48
|
+
the marker line continues on the following lines until the braces balance —
|
|
49
|
+
single-line items parse exactly as before. Folds break at commas (the
|
|
50
|
+
schema example's shape): a break that does not end the accumulated text
|
|
51
|
+
with a comma (or the opening brace) would merge the next key into the
|
|
52
|
+
previous value, so the join records a :class:`FoldIssue` and G10 reports it
|
|
53
|
+
(:code:`G10.fold_break_not_comma`); a fold that never balances by the end
|
|
54
|
+
of the block records :code:`G10.fold_unterminated` (fail-closed, with the
|
|
55
|
+
remaining shape errors still firing).
|
|
56
|
+
|
|
57
|
+
``dd:`` is the R3 challenge channel, never an observation link: values
|
|
58
|
+
carried by positive (S0) findings are excluded from the challenge face and
|
|
59
|
+
reported as structural errors by G10 (issue #44).
|
|
47
60
|
"""
|
|
48
61
|
from __future__ import annotations
|
|
49
62
|
|
|
@@ -51,6 +64,8 @@ import re
|
|
|
51
64
|
from dataclasses import dataclass, field
|
|
52
65
|
from typing import Any
|
|
53
66
|
|
|
67
|
+
from design_playbook.scripts.g2_g4_pointback import _findings
|
|
68
|
+
|
|
54
69
|
# --- closed enums (design-prototype 4.1 machine face) -----------------------
|
|
55
70
|
|
|
56
71
|
DD_TIERS = frozenset({"record", "compare", "explore"})
|
|
@@ -120,6 +135,7 @@ class DDEntry:
|
|
|
120
135
|
supersedes: str = ""
|
|
121
136
|
stale: str = ""
|
|
122
137
|
stale_review: dict[str, str] = field(default_factory=dict)
|
|
138
|
+
fold_issues: tuple["FoldIssue", ...] = ()
|
|
123
139
|
block: str = ""
|
|
124
140
|
|
|
125
141
|
# -- convenience accessors ------------------------------------------
|
|
@@ -168,6 +184,23 @@ class DDEntry:
|
|
|
168
184
|
return [item.get("id", "") for item in self.candidates]
|
|
169
185
|
|
|
170
186
|
|
|
187
|
+
@dataclass(frozen=True)
|
|
188
|
+
class FoldIssue:
|
|
189
|
+
"""A fold defect found while joining folded flow-map items.
|
|
190
|
+
|
|
191
|
+
``kind`` is ``"break_not_comma"`` (the accumulated fold text did not end
|
|
192
|
+
with a comma — or the opening brace — when a continuation was appended,
|
|
193
|
+
so the next key merges into the previous value) or ``"unterminated"``
|
|
194
|
+
(the braces never balanced before the block ended). ``line`` is the
|
|
195
|
+
1-based line of the fold-opening marker inside the entry block;
|
|
196
|
+
``tail`` carries the offending text tail for the error face.
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
kind: str
|
|
200
|
+
line: int
|
|
201
|
+
tail: str = ""
|
|
202
|
+
|
|
203
|
+
|
|
171
204
|
@dataclass(frozen=True)
|
|
172
205
|
class ESignals:
|
|
173
206
|
"""Machine-judgeable E-criterion signals gathered from run artifacts."""
|
|
@@ -208,6 +241,55 @@ def _flow_map(item: str) -> dict[str, str]:
|
|
|
208
241
|
return {"value": _scalar(item)}
|
|
209
242
|
|
|
210
243
|
|
|
244
|
+
def _join_folded_flow_maps(
|
|
245
|
+
lines: list[str]) -> tuple[list[str], tuple[FoldIssue, ...]]:
|
|
246
|
+
"""Join folded ``- {...}`` flow-map items onto their marker line.
|
|
247
|
+
|
|
248
|
+
Issue #44: an item that opens a ``{`` without closing it on the marker
|
|
249
|
+
line continues on the following lines (the fold the entry schema
|
|
250
|
+
example already shows) until its braces balance; continuation text is
|
|
251
|
+
appended to the marker line so the rest of the parser sees one logical
|
|
252
|
+
line. Values never contain ASCII commas or braces (declared shape), so
|
|
253
|
+
brace balance is an unambiguous fold terminator. Folds break at commas:
|
|
254
|
+
a continuation appended to accumulated text that does not end with a
|
|
255
|
+
comma (or the opening brace) would silently merge the next key into the
|
|
256
|
+
previous value, so the break is recorded as a :class:`FoldIssue`
|
|
257
|
+
(fail-closed; G10 reports it). An unterminated fold stays joined and
|
|
258
|
+
records its own issue plus the downstream shape checks (fail-closed).
|
|
259
|
+
Returns ``(joined lines, fold issues)``.
|
|
260
|
+
"""
|
|
261
|
+
out: list[str] = []
|
|
262
|
+
issues: list[FoldIssue] = []
|
|
263
|
+
fold: str | None = None
|
|
264
|
+
fold_line = 0
|
|
265
|
+
for lineno, raw_line in enumerate(lines, 1):
|
|
266
|
+
stripped = raw_line.strip()
|
|
267
|
+
if fold is not None:
|
|
268
|
+
if not fold.rstrip().endswith((",", "{")):
|
|
269
|
+
issues.append(FoldIssue(
|
|
270
|
+
kind="break_not_comma",
|
|
271
|
+
line=fold_line,
|
|
272
|
+
tail=fold.strip()[-60:],
|
|
273
|
+
))
|
|
274
|
+
fold = fold.rstrip() + " " + stripped
|
|
275
|
+
if fold.count("{") <= fold.count("}"):
|
|
276
|
+
out.append(fold)
|
|
277
|
+
fold = None
|
|
278
|
+
continue
|
|
279
|
+
if (
|
|
280
|
+
stripped.startswith("- ")
|
|
281
|
+
and stripped.count("{") > stripped.count("}")
|
|
282
|
+
):
|
|
283
|
+
fold = raw_line.rstrip()
|
|
284
|
+
fold_line = lineno
|
|
285
|
+
continue
|
|
286
|
+
out.append(raw_line)
|
|
287
|
+
if fold is not None:
|
|
288
|
+
issues.append(FoldIssue(kind="unterminated", line=fold_line))
|
|
289
|
+
out.append(fold)
|
|
290
|
+
return out, tuple(issues)
|
|
291
|
+
|
|
292
|
+
|
|
211
293
|
def _entry_blocks(text: str) -> list[tuple[str, str]]:
|
|
212
294
|
"""Split the report into (id, body) blocks by DD entry heading."""
|
|
213
295
|
matches = list(DD_HEADING.finditer(text))
|
|
@@ -231,7 +313,8 @@ def _parse_entry(entry_id: str, body: str) -> DDEntry:
|
|
|
231
313
|
current_section: str | None = None
|
|
232
314
|
current_list_key: str | None = None
|
|
233
315
|
|
|
234
|
-
|
|
316
|
+
joined, fold_issues = _join_folded_flow_maps(block.splitlines())
|
|
317
|
+
for raw_line in joined:
|
|
235
318
|
stripped = raw_line.strip()
|
|
236
319
|
if not stripped or stripped.startswith("```"):
|
|
237
320
|
continue
|
|
@@ -307,6 +390,7 @@ def _parse_entry(entry_id: str, body: str) -> DDEntry:
|
|
|
307
390
|
supersedes=fields.get("supersedes", ""),
|
|
308
391
|
stale=fields.get("stale", ""),
|
|
309
392
|
stale_review=_scalars("stale_review"),
|
|
393
|
+
fold_issues=fold_issues,
|
|
310
394
|
block=block,
|
|
311
395
|
)
|
|
312
396
|
|
|
@@ -319,20 +403,74 @@ def parse_dd_entries(text: str) -> list[DDEntry]:
|
|
|
319
403
|
]
|
|
320
404
|
|
|
321
405
|
|
|
406
|
+
# ``none`` value token for the verbatim top block: a trailing same-line
|
|
407
|
+
# note after the token is tolerated commentary, never a declared change
|
|
408
|
+
# (issue #44). The token must end at whitespace or punctuation so values
|
|
409
|
+
# like ``nonempty`` and ``none_x`` stay fail-closed non-none (underscore
|
|
410
|
+
# joins the lookalike continuation class with ``-``, e.g. ``none-such``).
|
|
411
|
+
NONE_VALUE = re.compile(r"none(?=$|[^0-9A-Za-z_-])", re.I)
|
|
412
|
+
|
|
413
|
+
|
|
322
414
|
def top_block_baseline_change(text: str) -> bool:
|
|
323
|
-
"""True when the verbatim top block declares ``baseline-changes != none``.
|
|
415
|
+
"""True when the verbatim top block declares ``baseline-changes != none``.
|
|
416
|
+
|
|
417
|
+
``none`` may carry a trailing same-line note (anything after the value
|
|
418
|
+
token); notes never turn ``none`` into a declared change. Substantive
|
|
419
|
+
commentary belongs on its own line.
|
|
420
|
+
"""
|
|
324
421
|
match = re.search(r"^baseline-changes:[ \t]*(\S.*)$", text, re.M)
|
|
325
422
|
if match is None:
|
|
326
423
|
return False
|
|
327
|
-
return match.group(1).strip()
|
|
424
|
+
return NONE_VALUE.match(match.group(1).strip()) is None
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def is_positive_finding(parsed: dict[str, list[str]]) -> bool:
|
|
428
|
+
"""True when a parsed point-back finding sits on the S0 (info) axis."""
|
|
429
|
+
values = parsed.get("severity") or [""]
|
|
430
|
+
return values[0].strip().casefold() == "s0"
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def positive_dd_refs(
|
|
434
|
+
text: str) -> tuple[tuple[int, tuple[str, ...]], ...]:
|
|
435
|
+
"""``(finding index, dd refs)`` for every positive finding carrying ``dd:``.
|
|
436
|
+
|
|
437
|
+
Issue #44: ``dd:`` is the R3 challenge channel and never rides a
|
|
438
|
+
positive observation. These are structural errors G10 reports
|
|
439
|
+
(fail-closed) instead of silently reading them as challenges.
|
|
440
|
+
"""
|
|
441
|
+
out: list[tuple[int, tuple[str, ...]]] = []
|
|
442
|
+
for index, parsed in enumerate(_findings(text), 1):
|
|
443
|
+
refs = tuple(
|
|
444
|
+
value.strip().rstrip(",") for value in parsed.get("dd", [])
|
|
445
|
+
if value.strip())
|
|
446
|
+
if refs and is_positive_finding(parsed):
|
|
447
|
+
out.append((index, refs))
|
|
448
|
+
return tuple(out)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _positive_dd_block(block: str) -> bool:
|
|
452
|
+
return any(
|
|
453
|
+
parsed.get("dd") and is_positive_finding(parsed)
|
|
454
|
+
for parsed in _findings(block)
|
|
455
|
+
)
|
|
328
456
|
|
|
329
457
|
|
|
330
458
|
def dd_refs_in_pointback(text: str) -> tuple[str, ...]:
|
|
331
|
-
"""Collect ``dd:`` targets from finding field lines
|
|
459
|
+
"""Collect ``dd:`` challenge targets from finding field lines.
|
|
460
|
+
|
|
461
|
+
Issue #44: ``dd:`` values carried by positive (S0) findings record
|
|
462
|
+
observation links, not challenges — their paragraphs are skipped so a
|
|
463
|
+
positive observation can never fire a false re-entry / E3 signal.
|
|
464
|
+
Paragraphs that are not findings (e.g. a bare ``dd:`` line) keep the
|
|
465
|
+
legacy raw face.
|
|
466
|
+
"""
|
|
332
467
|
targets: list[str] = []
|
|
333
|
-
for
|
|
334
|
-
|
|
335
|
-
|
|
468
|
+
for block in re.split(r"\n\s*\n", text):
|
|
469
|
+
if _positive_dd_block(block):
|
|
470
|
+
continue
|
|
471
|
+
for match in re.finditer(r"^dd:[ \t]*(\S+)", block, re.I | re.M):
|
|
472
|
+
ref = match.group(1).strip().rstrip(",")
|
|
473
|
+
targets.append(ref)
|
|
336
474
|
return tuple(targets)
|
|
337
475
|
|
|
338
476
|
|
|
@@ -41,6 +41,7 @@ import re
|
|
|
41
41
|
from dataclasses import dataclass
|
|
42
42
|
|
|
43
43
|
from design_playbook.scripts._diagnostics import Finding, finding
|
|
44
|
+
from design_playbook.scripts.dd_entries import is_positive_finding
|
|
44
45
|
from design_playbook.scripts.g2_g4_pointback import _findings
|
|
45
46
|
from design_playbook.scripts.repair_rounds import is_blocking
|
|
46
47
|
|
|
@@ -167,9 +168,16 @@ def check_routes(text: str) -> list[Finding]:
|
|
|
167
168
|
|
|
168
169
|
|
|
169
170
|
def dd_targets(text: str) -> tuple[str, ...]:
|
|
170
|
-
"""dd: references carried by findings (R3 challenge face).
|
|
171
|
+
"""dd: references carried by findings (R3 challenge face).
|
|
172
|
+
|
|
173
|
+
Issue #44: positive (S0) findings carry observation links, not
|
|
174
|
+
challenges — their ``dd:`` values never fire E3 (G10 reports the
|
|
175
|
+
misuse as a structural error instead).
|
|
176
|
+
"""
|
|
171
177
|
targets: list[str] = []
|
|
172
178
|
for parsed in _findings(text):
|
|
179
|
+
if is_positive_finding(parsed):
|
|
180
|
+
continue
|
|
173
181
|
targets.extend(value.strip() for value in parsed.get("dd", [])
|
|
174
182
|
if value.strip())
|
|
175
183
|
return tuple(targets)
|
|
@@ -6,8 +6,9 @@ declared by the protocol — entry completeness, tier/status enums, tier
|
|
|
6
6
|
recording obligations (R one-line rationale / C trade-off record / E user
|
|
7
7
|
confirmation), supersedes existence + acyclicity, registry rule-reference
|
|
8
8
|
cross-check, preview transaction linkage (decision_id), R3 re-entry
|
|
9
|
-
resolution (dd: challenges must end invalidated with an E-tier revision
|
|
10
|
-
|
|
9
|
+
resolution (dd: challenges must end invalidated with an E-tier revision;
|
|
10
|
+
``dd:`` on a positive finding is a shape error, issue #44), and the
|
|
11
|
+
baseline-drift stale review (three exits: keep / revise / escalate).
|
|
11
12
|
|
|
12
13
|
Comparison-matrix quality, trade-off sufficiency, and tier-grading
|
|
13
14
|
judgement calls (composition change, identity drift beyond declared
|
|
@@ -35,6 +36,7 @@ from design_playbook.scripts.dd_entries import (
|
|
|
35
36
|
is_cross_run_ref,
|
|
36
37
|
local_dd_id,
|
|
37
38
|
parse_dd_entries,
|
|
39
|
+
positive_dd_refs,
|
|
38
40
|
)
|
|
39
41
|
|
|
40
42
|
# rules.md ships inside the package (read-only protocol consumption, the
|
|
@@ -102,6 +104,11 @@ def _entry_checks(entries: list[DDEntry]) -> list[Finding]:
|
|
|
102
104
|
))
|
|
103
105
|
seen[label] = index
|
|
104
106
|
|
|
107
|
+
# fold defects first (issue #44 follow-up): a named unterminated /
|
|
108
|
+
# comma-less fold error outranks the indirect missing_* findings it
|
|
109
|
+
# causes downstream, so the error face points at the real defect.
|
|
110
|
+
errs += _fold_checks(entry)
|
|
111
|
+
|
|
105
112
|
for key in ("id", "tier", "question", "status"):
|
|
106
113
|
if not entry.fields.get(key, "").strip():
|
|
107
114
|
errs.append(finding(
|
|
@@ -143,6 +150,45 @@ def _entry_checks(entries: list[DDEntry]) -> list[Finding]:
|
|
|
143
150
|
return errs
|
|
144
151
|
|
|
145
152
|
|
|
153
|
+
def _fold_checks(entry: DDEntry) -> list[Finding]:
|
|
154
|
+
"""Fold defects on ``- {…}`` items (issue #44 follow-up, fail-closed).
|
|
155
|
+
|
|
156
|
+
A fold break without a comma merges the next key into the previous
|
|
157
|
+
value (the parse alone would accept it silently); a fold that never
|
|
158
|
+
balances swallows the rest of the block and only indirect missing_*
|
|
159
|
+
errors would fire. Both get a named error up front, with the block line
|
|
160
|
+
of the fold-opening marker; the remaining shape errors still fire.
|
|
161
|
+
"""
|
|
162
|
+
errs: list[Finding] = []
|
|
163
|
+
for issue in entry.fold_issues:
|
|
164
|
+
if issue.kind == "unterminated":
|
|
165
|
+
errs.append(finding(
|
|
166
|
+
"G10.fold_unterminated",
|
|
167
|
+
f"G10 decisions: {entry.id} opens a folded flow-map item at "
|
|
168
|
+
f"block line {issue.line} that never closes its brace — the "
|
|
169
|
+
"fold swallowed the rest of the entry block",
|
|
170
|
+
owner=_fmt(entry.id),
|
|
171
|
+
expected="braces balance inside the entry block",
|
|
172
|
+
actual=f"unterminated fold from block line {issue.line}",
|
|
173
|
+
repair="Close the brace, or unfold to the canonical "
|
|
174
|
+
"single-line item",
|
|
175
|
+
))
|
|
176
|
+
else:
|
|
177
|
+
errs.append(finding(
|
|
178
|
+
"G10.fold_break_not_comma",
|
|
179
|
+
f"G10 decisions: {entry.id} folds a flow-map item at block "
|
|
180
|
+
f"line {issue.line} without a comma at the break — the next "
|
|
181
|
+
"key merges into the previous value",
|
|
182
|
+
owner=_fmt(entry.id),
|
|
183
|
+
expected="fold breaks end with a comma (or the opening "
|
|
184
|
+
"brace)",
|
|
185
|
+
actual=f"break after {issue.tail!r}",
|
|
186
|
+
repair="End the folded line with a comma before continuing "
|
|
187
|
+
"the item on the next line",
|
|
188
|
+
))
|
|
189
|
+
return errs
|
|
190
|
+
|
|
191
|
+
|
|
146
192
|
def _candidate_checks(entry: DDEntry) -> list[Finding]:
|
|
147
193
|
errs: list[Finding] = []
|
|
148
194
|
label = entry.id
|
|
@@ -669,6 +715,31 @@ def _reentry_checks(
|
|
|
669
715
|
return errs
|
|
670
716
|
|
|
671
717
|
|
|
718
|
+
def _positive_dd_checks(pointback_text: str | None) -> list[Finding]:
|
|
719
|
+
"""``dd:`` on a positive (S0) finding is a shape error (issue #44).
|
|
720
|
+
|
|
721
|
+
``dd:`` is the R3 challenge channel; riding it on a positive
|
|
722
|
+
observation reads as a challenge downstream. Fail closed with a
|
|
723
|
+
precise error instead of silently ignoring the line.
|
|
724
|
+
"""
|
|
725
|
+
if not pointback_text:
|
|
726
|
+
return []
|
|
727
|
+
errs: list[Finding] = []
|
|
728
|
+
for index, refs in positive_dd_refs(pointback_text):
|
|
729
|
+
errs.append(finding(
|
|
730
|
+
"G10.dd_on_positive_finding",
|
|
731
|
+
f"G10 decisions: positive finding {index} carries "
|
|
732
|
+
f"dd: {', '.join(refs)} — dd: is the R3 challenge channel and "
|
|
733
|
+
"never rides a positive observation",
|
|
734
|
+
owner=f"point-back.md#finding.{index}",
|
|
735
|
+
expected="dd: only on non-positive (S1-S3) findings",
|
|
736
|
+
actual="dd: on severity S0",
|
|
737
|
+
repair="Drop the dd: line and record the observation link as "
|
|
738
|
+
"prose (e.g. an evidence note line)",
|
|
739
|
+
))
|
|
740
|
+
return errs
|
|
741
|
+
|
|
742
|
+
|
|
672
743
|
def _preview_link_checks(
|
|
673
744
|
entries: list[DDEntry],
|
|
674
745
|
preview_dir: Path | None) -> list[Finding]:
|
|
@@ -929,6 +1000,7 @@ def check_g10(
|
|
|
929
1000
|
report_text=report_text,
|
|
930
1001
|
)
|
|
931
1002
|
errs += _reentry_checks(entries, signals.dd_targets)
|
|
1003
|
+
errs += _positive_dd_checks(pointback_text)
|
|
932
1004
|
errs += _preview_link_checks(entries, preview_dir)
|
|
933
1005
|
errs += _stale_checks(entries, baseline_state)
|
|
934
1006
|
errs += _signal_checks(entries, signals, run_profile_tier)
|
|
@@ -37,7 +37,10 @@ if str(_PKG_ROOT) not in sys.path:
|
|
|
37
37
|
from design_playbook.scripts import rules_registry # noqa: E402
|
|
38
38
|
from design_playbook.scripts._diagnostics import Finding, finding # noqa: E402
|
|
39
39
|
from design_playbook.scripts.rules_registry import RegistryError # noqa: E402
|
|
40
|
-
from design_playbook.scripts.run_profile import
|
|
40
|
+
from design_playbook.scripts.run_profile import ( # noqa: E402
|
|
41
|
+
parse_run_profile,
|
|
42
|
+
validate_run_profile,
|
|
43
|
+
)
|
|
41
44
|
|
|
42
45
|
REGISTRY_PATH = (
|
|
43
46
|
Path(__file__).resolve().parents[1] / "skills" / "design-playbook"
|
|
@@ -144,9 +147,16 @@ def main(argv: list[str]) -> int:
|
|
|
144
147
|
try:
|
|
145
148
|
profile = parse_run_profile(
|
|
146
149
|
plan_path.read_text(encoding="utf-8"))
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
+
except (OSError, UnicodeError) as exc:
|
|
151
|
+
print(f"G8 INVALID: cannot read {plan_path}: {exc}", file=sys.stderr)
|
|
152
|
+
return 2
|
|
153
|
+
profile_errors = validate_run_profile(profile) if profile is not None else []
|
|
154
|
+
if profile_errors:
|
|
155
|
+
print("G8 INVALID:")
|
|
156
|
+
for error in profile_errors:
|
|
157
|
+
print(f" FAIL run-profile invalid: {error}")
|
|
158
|
+
return 1
|
|
159
|
+
tier = profile.tier if profile is not None else None
|
|
150
160
|
findings = check_g8_run(craft_text, entries, tier)
|
|
151
161
|
if not findings:
|
|
152
162
|
print("G8 OK: craft audit rows satisfy the run-level registry gate")
|
|
@@ -36,9 +36,9 @@ from design_playbook.scripts.g2_g4_pointback import FIELD_LINE
|
|
|
36
36
|
MIN_DISTINCT_RUNS = 3
|
|
37
37
|
MIN_DISTINCT_CONTEXTS = 2
|
|
38
38
|
MAX_UNEXPLAINED_FALSE_POSITIVES = 0
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
-
|
|
39
|
+
# Candidate derivation fails closed: only defect severities enter history.
|
|
40
|
+
# S0 is a positive observation; blank, legacy, and unknown values are invalid.
|
|
41
|
+
CANDIDATE_SEVERITIES = frozenset({"S3", "S2", "S1"})
|
|
42
42
|
|
|
43
43
|
UNSPECIFIED_CONTEXT = "(unspecified)"
|
|
44
44
|
|
|
@@ -134,8 +134,8 @@ def derive_candidates(
|
|
|
134
134
|
"""
|
|
135
135
|
groups: dict[str, list[Occurrence]] = {}
|
|
136
136
|
for occurrence in occurrences:
|
|
137
|
-
if occurrence.severity.strip() in
|
|
138
|
-
continue
|
|
137
|
+
if occurrence.severity.strip() not in CANDIDATE_SEVERITIES:
|
|
138
|
+
continue
|
|
139
139
|
key = normalize(occurrence.issue)
|
|
140
140
|
if key:
|
|
141
141
|
groups.setdefault(key, []).append(occurrence)
|