frontface-proofit 1.0.0__py3-none-any.whl
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.
- frontface_proofit-1.0.0.dist-info/METADATA +163 -0
- frontface_proofit-1.0.0.dist-info/RECORD +13 -0
- frontface_proofit-1.0.0.dist-info/WHEEL +5 -0
- frontface_proofit-1.0.0.dist-info/entry_points.txt +2 -0
- frontface_proofit-1.0.0.dist-info/licenses/LICENSE +21 -0
- frontface_proofit-1.0.0.dist-info/top_level.txt +1 -0
- proofit/__init__.py +16 -0
- proofit/__main__.py +130 -0
- proofit/checks.py +490 -0
- proofit/ledger.py +132 -0
- proofit/linter.py +21 -0
- proofit/model.py +104 -0
- proofit/parser.py +153 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: frontface-proofit
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Fail-closed linter for locked-down runbooks: ten rules of workflow discipline as code
|
|
5
|
+
Author: Ryan Wesley Hitchcock
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/ohpersonperson/frontface
|
|
8
|
+
Keywords: linter,runbook,workflow,technical-writing,validation
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Dynamic: license-file
|
|
16
|
+
|
|
17
|
+
# proofit — fail-closed linter for locked-down runbooks
|
|
18
|
+
|
|
19
|
+
A runbook is a step-by-step procedure written so tightly that two different
|
|
20
|
+
people following it do the same thing and get the same result. proofit checks
|
|
21
|
+
a runbook draft against ten rules of workflow discipline — and **rejects the
|
|
22
|
+
draft if any rule is violated**. No warnings, no advisories. Fail-closed:
|
|
23
|
+
exit 1 until it's fixed, exit 0 when it's locked down.
|
|
24
|
+
|
|
25
|
+
It checks the runbook's **shape, not its truth**: it can tell you a step is
|
|
26
|
+
missing its verification block, not whether the verification would actually
|
|
27
|
+
work. A fully-specified unwise workflow passes; a wise but sloppy one doesn't.
|
|
28
|
+
That distinction is the whole product — see the FAQ.
|
|
29
|
+
|
|
30
|
+
No prerequisites. You don't need to know anything about this project's
|
|
31
|
+
history or any framework. The mechanism is the whole product.
|
|
32
|
+
|
|
33
|
+
*(Lineage: this is the front-facing conversion of the `proofit` skill from
|
|
34
|
+
Ryan's skill-suite, MIT. What changed in the conversion is documented in
|
|
35
|
+
CHANGELOG.md.)*
|
|
36
|
+
|
|
37
|
+
## What it does
|
|
38
|
+
|
|
39
|
+
You write the runbook as Markdown in the template shape
|
|
40
|
+
(`templates/runbook-template.md` — or print it with `python -m proofit
|
|
41
|
+
template`):
|
|
42
|
+
|
|
43
|
+
- **Ground Rules & Prerequisites** — baseline, routing specs per service,
|
|
44
|
+
tools/items, expected outcome
|
|
45
|
+
- **Step-by-Step Directives** — numbered steps, each with **Do:** /
|
|
46
|
+
**Look for:** / **Verify:** / **If wrong:** / **Fix:**
|
|
47
|
+
- **Validation & Closure** — success condition plus consistency checkpoints
|
|
48
|
+
|
|
49
|
+
The linter parses that shape and runs ten checks:
|
|
50
|
+
|
|
51
|
+
| # | Rule | How it's checked |
|
|
52
|
+
|---|------|------------------|
|
|
53
|
+
| 1 | Remove every assumption | Heuristic: flags bare unlabeled directives ("Press Continue") and claims of automatic behavior ("is done automatically"). Narrow by design — see below. |
|
|
54
|
+
| 2 | All prerequisites upfront | Structure: the section must exist before step 1 and cover all eight fields (hardware, software, accounts, permissions, files, internet access, free-tier limits, starting state). |
|
|
55
|
+
| 3 | One action per step | Structure: each `Do:` holds exactly one verb-led action; steps numbered 1..N. |
|
|
56
|
+
| 4 | Concrete references | Flags bare UI nouns ("the button") and vague references ("open the file") with no visible label. Qualified nouns ("a confirmation dialog") don't fire. |
|
|
57
|
+
| 5 | Explain technical terms | Convention: where a glossary exists, every entry must match `Term (definition)`. Detecting undefined jargon *without* a glossary is out of scope — stated, not pretended. |
|
|
58
|
+
| 6 | Verification after every milestone | Structure: every step ends with a `Verify:` block in observable language ("displays", "exists", "contains"). |
|
|
59
|
+
| 7 | Immediate recovery | Structure: every step carries `If wrong:` + `Fix:`. |
|
|
60
|
+
| 8 | Never skip state changes | Heuristic: a `Do:` starting with a state-changing verb (open, close, install, …) must describe the resulting state in transition language ("a dialog appears"). Narrow by design. |
|
|
61
|
+
| 9 | Routing decisions upfront | Schema: per named service — when to use, free-tier limits, verbatim prompt, expected output format — plus rate-limit handling and error paths. |
|
|
62
|
+
| 10 | Finish with full validation | Structure: the document ends with Validation & Closure (never a step), with a success condition and a checklist. |
|
|
63
|
+
|
|
64
|
+
## Why it works
|
|
65
|
+
|
|
66
|
+
Most workflow failures are specification failures, not execution failures.
|
|
67
|
+
Someone improvises mid-stream because the routing decision was never written
|
|
68
|
+
down. Someone skips a prerequisite because it was revealed in step 9. Someone
|
|
69
|
+
combines two actions in one step and does them in the wrong order. The ten
|
|
70
|
+
rules attack exactly these failure modes, and the linter makes them
|
|
71
|
+
mechanical: the rules that can be checked as structure *are* checked as
|
|
72
|
+
structure, so compliance isn't a matter of discipline — it's a matter of
|
|
73
|
+
passing the gate.
|
|
74
|
+
|
|
75
|
+
Two rules (1 and 8) are phrase-list heuristics — tripwires, not proof. Under
|
|
76
|
+
fail-closed they still block, so they are deliberately narrow: they fire only
|
|
77
|
+
on clear hits. A foolproof gate that misfires on good drafts isn't foolproof,
|
|
78
|
+
it's a tax. The narrowness is documented in the code and tested.
|
|
79
|
+
|
|
80
|
+
## Quickstart
|
|
81
|
+
|
|
82
|
+
Zero dependencies beyond Python 3.10+.
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
cd proofit
|
|
86
|
+
PYTHONPATH=src python3 -m unittest discover -s tests # 65 offline tests
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Lint a draft:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
PYTHONPATH=src python3 -m proofit lint my-runbook.md
|
|
93
|
+
# violations listed as RULE n [location] ERROR — message + fix
|
|
94
|
+
# exit 1: REJECTED — fix and re-run
|
|
95
|
+
# exit 0: PASS — the runbook is locked down
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Print the blank template:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
PYTHONPATH=src python3 -m proofit template > my-runbook.md
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Or use it as a library:
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
from proofit import lint_text
|
|
108
|
+
|
|
109
|
+
result = lint_text(open("my-runbook.md").read())
|
|
110
|
+
if not result.passed:
|
|
111
|
+
for v in result.violations:
|
|
112
|
+
print(v.render())
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
See `examples/run_local.py` for the full cycle (broken draft rejected,
|
|
116
|
+
fixed draft passes).
|
|
117
|
+
|
|
118
|
+
## Inputs, outputs
|
|
119
|
+
|
|
120
|
+
- **Input:** a runbook draft in Markdown following the template shape
|
|
121
|
+
(`templates/runbook-template.md`, or print it with `python -m proofit
|
|
122
|
+
template`). Freeform Markdown is out of scope **by design, not by
|
|
123
|
+
accident**: the linter parses the template's sections, so a document that
|
|
124
|
+
isn't in the template shape fails wholesale on missing sections rather
|
|
125
|
+
than producing meaningful per-rule violations. Write it in the template,
|
|
126
|
+
get it checked — anything else isn't a bad runbook to proofit, it's not
|
|
127
|
+
a runbook at all.
|
|
128
|
+
- **Output:** a list of violations (`RULE n [location] ERROR — message`
|
|
129
|
+
plus a `fix:` line), and an exit code: 1 if any violation exists, 0 if
|
|
130
|
+
clean. Fail-closed — there is no warning level.
|
|
131
|
+
|
|
132
|
+
## Worked example
|
|
133
|
+
|
|
134
|
+
`examples/example-1-lint-cycle.md` — the same workflow as a broken draft
|
|
135
|
+
(18 violations, rejected) and a fixed draft (passes). The fixture files
|
|
136
|
+
`examples/broken-runbook.md` and `examples/fixed-runbook.md` are the
|
|
137
|
+
actual inputs; the test suite asserts the broken one yields exactly 18
|
|
138
|
+
violations across all ten rules and the fixed one yields zero.
|
|
139
|
+
|
|
140
|
+
## FAQ
|
|
141
|
+
|
|
142
|
+
**Does it check whether the runbook is *correct*?** No. It checks the
|
|
143
|
+
runbook's shape, not its truth — it can't tell whether a stated rate limit
|
|
144
|
+
is right or a recovery path works. It guarantees the workflow is fully
|
|
145
|
+
specified, not that it is wise.
|
|
146
|
+
|
|
147
|
+
**What if a heuristic fires on a good draft?** Rules 1 and 8 are narrow by
|
|
148
|
+
design, but false positives are possible (documented in `checks.py`). If
|
|
149
|
+
one blocks a draft you believe is correct, the honest move is to reword past
|
|
150
|
+
the tripwire — the rewording is usually clearer anyway — not to fork the
|
|
151
|
+
linter.
|
|
152
|
+
|
|
153
|
+
**Why fail-closed instead of warnings?** Because the skill's promise is
|
|
154
|
+
"unbreakable, zero-assumption directive." A warning you can ignore is the
|
|
155
|
+
opposite of idiot-proof. The gate is the product.
|
|
156
|
+
|
|
157
|
+
**Single-service workflow — do I need the routing section?** No. Rule 9 is
|
|
158
|
+
vacuous when no services are named. Delete the Routing Architecture section
|
|
159
|
+
and the check passes silently.
|
|
160
|
+
|
|
161
|
+
## License
|
|
162
|
+
|
|
163
|
+
MIT. See LICENSE.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
frontface_proofit-1.0.0.dist-info/licenses/LICENSE,sha256=Zx_XafyZDq7qBexUb7tER9kYlBZ0rCVaAwKuqMCfxLE,1078
|
|
2
|
+
proofit/__init__.py,sha256=u6VfGkv38hyNB1wSdtQqsYDZeLD8PtQIfHRdPf4vXTo,351
|
|
3
|
+
proofit/__main__.py,sha256=chyGF0o_zRGXzpfsPXXfNFfEPJZo9yMB0vxt_2z37dk,4439
|
|
4
|
+
proofit/checks.py,sha256=GCmTxU0OAFskLHKkl68Yr6NEICyWDdjqGHNGNpaNWSU,21443
|
|
5
|
+
proofit/ledger.py,sha256=x8TH2et1Jt_UTzLMn49R8YKEiMMykYTW6UoMz-l8qCU,4245
|
|
6
|
+
proofit/linter.py,sha256=ecu2ZDJZh57lSzwV9R5v7ZuCalJMRBINbqeTwKyyEPU,734
|
|
7
|
+
proofit/model.py,sha256=UUI0AYD8YzhRLRUtlfrjivyIPvaQX0QDeJHDmivcZK4,2994
|
|
8
|
+
proofit/parser.py,sha256=Hc59xsoGbbDx0nFgzNdjbefjo0ICYsbaQ_bGTzi1TLc,5500
|
|
9
|
+
frontface_proofit-1.0.0.dist-info/METADATA,sha256=qeU5CuJyMvaDTbhhxqrg7f_NDyMT5RxbBW1cvrzSaNY,7597
|
|
10
|
+
frontface_proofit-1.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
11
|
+
frontface_proofit-1.0.0.dist-info/entry_points.txt,sha256=lq8bTPZnHRhEdpIW2oT0ZwxGnTkrpBKYEnNpqoeZ8_M,50
|
|
12
|
+
frontface_proofit-1.0.0.dist-info/top_level.txt,sha256=yDyoKQj29kURcq-c6TQr4fkTPJu7uaJRCZf-7uBL_xg,8
|
|
13
|
+
frontface_proofit-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ryan Wesley Hitchcock
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
proofit
|
proofit/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""proofit — fail-closed linter for locked-down runbooks."""
|
|
2
|
+
from .linter import lint_file, lint_text
|
|
3
|
+
from .model import LintResult, Runbook, Step, Violation
|
|
4
|
+
from .parser import parse
|
|
5
|
+
|
|
6
|
+
__version__ = "1.0.0"
|
|
7
|
+
__all__ = [
|
|
8
|
+
"lint_file",
|
|
9
|
+
"lint_text",
|
|
10
|
+
"parse",
|
|
11
|
+
"LintResult",
|
|
12
|
+
"Runbook",
|
|
13
|
+
"Step",
|
|
14
|
+
"Violation",
|
|
15
|
+
"__version__",
|
|
16
|
+
]
|
proofit/__main__.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""CLI: python -m proofit lint runbook.md | python -m proofit template
|
|
2
|
+
|
|
3
|
+
Fail-closed on ERRORs: any structural violation rejects the draft (exit 1).
|
|
4
|
+
WARNINGs (heuristic/convention rules) are listed but don't fail the draft —
|
|
5
|
+
unless --strict is passed, which restores fail-on-anything.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
from . import __version__, lint_file
|
|
13
|
+
from .ledger import Ledger
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _template_path() -> str:
|
|
17
|
+
here = os.path.dirname(os.path.abspath(__file__))
|
|
18
|
+
return os.path.normpath(os.path.join(here, "..", "..", "templates", "runbook-template.md"))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def cmd_lint(path: str, *, strict: bool = False) -> int:
|
|
22
|
+
try:
|
|
23
|
+
result = lint_file(path, strict=strict)
|
|
24
|
+
except FileNotFoundError:
|
|
25
|
+
print(f"error: file not found: {path}", file=sys.stderr)
|
|
26
|
+
return 2
|
|
27
|
+
except OSError as e:
|
|
28
|
+
print(f"error: cannot read {path}: {e}", file=sys.stderr)
|
|
29
|
+
return 2
|
|
30
|
+
_record_lint(path, result)
|
|
31
|
+
for v in result.violations:
|
|
32
|
+
print(v.render())
|
|
33
|
+
if not result.passed:
|
|
34
|
+
# errors, or (strict mode and warnings)
|
|
35
|
+
n = len(result.violations)
|
|
36
|
+
print(f"\nREJECTED: {n} violation{'s' if n != 1 else ''} — fix and re-run.")
|
|
37
|
+
return 1
|
|
38
|
+
if result.warnings:
|
|
39
|
+
n = len(result.warnings)
|
|
40
|
+
print(f"\nWARNINGS: {n} warning{'s' if n != 1 else ''} — pass "
|
|
41
|
+
f"(warnings don't fail the draft; use --strict to fail on them).")
|
|
42
|
+
return 0
|
|
43
|
+
print("PASS: no violations — the runbook is locked down.")
|
|
44
|
+
return 0
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _record_lint(path: str, result) -> None:
|
|
48
|
+
"""Append one lint record. Digest of the draft, never the draft."""
|
|
49
|
+
try:
|
|
50
|
+
with open(path, encoding="utf-8") as f:
|
|
51
|
+
content = f.read()
|
|
52
|
+
except OSError:
|
|
53
|
+
content = None
|
|
54
|
+
by_rule: dict[int, int] = {}
|
|
55
|
+
for v in result.violations:
|
|
56
|
+
by_rule[v.rule] = by_rule.get(v.rule, 0) + 1
|
|
57
|
+
Ledger("proofit").record(
|
|
58
|
+
"lint",
|
|
59
|
+
{
|
|
60
|
+
"passed": result.passed,
|
|
61
|
+
"violations": len(result.violations),
|
|
62
|
+
"by_rule": by_rule,
|
|
63
|
+
},
|
|
64
|
+
input_text=content,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def cmd_ledger(argv: list[str]) -> int:
|
|
69
|
+
led = Ledger("proofit")
|
|
70
|
+
s = led.summary()
|
|
71
|
+
print(f"proofit ledger: {s['records']} records "
|
|
72
|
+
f"({led.path})")
|
|
73
|
+
for ev, n in sorted(s["events"].items()):
|
|
74
|
+
print(f" {ev}: {n}")
|
|
75
|
+
if s["records"]:
|
|
76
|
+
print(f" first: {s['first']}")
|
|
77
|
+
print(f" last: {s['last']}")
|
|
78
|
+
try:
|
|
79
|
+
limit = int(argv[0]) if argv else 5
|
|
80
|
+
except ValueError:
|
|
81
|
+
limit = 5
|
|
82
|
+
print(f" recent {limit}:")
|
|
83
|
+
for r in led.read(limit=limit):
|
|
84
|
+
sm = r["summary"]
|
|
85
|
+
print(f" {r['ts']} lint "
|
|
86
|
+
f"passed={sm['passed']} violations={sm['violations']} "
|
|
87
|
+
f"by_rule={sm['by_rule']}")
|
|
88
|
+
return 0
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def cmd_template() -> int:
|
|
92
|
+
path = _template_path()
|
|
93
|
+
try:
|
|
94
|
+
with open(path, encoding="utf-8") as f:
|
|
95
|
+
sys.stdout.write(f.read())
|
|
96
|
+
except OSError as e:
|
|
97
|
+
print(f"error: template not found at {path}: {e}", file=sys.stderr)
|
|
98
|
+
return 2
|
|
99
|
+
return 0
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def main(argv: list[str] | None = None) -> int:
|
|
103
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
104
|
+
if not argv or argv[0] in ("-h", "--help", "help"):
|
|
105
|
+
print(f"proofit {__version__} — fail-closed runbook linter")
|
|
106
|
+
print("usage:")
|
|
107
|
+
print(" python -m proofit lint [--strict] <runbook.md>")
|
|
108
|
+
print(" check a draft; ERRORs reject (exit 1), WARNINGs pass unless --strict")
|
|
109
|
+
print(" python -m proofit template print the blank runbook template")
|
|
110
|
+
print(" python -m proofit ledger [n] show the local lint ledger (last n, default 5)")
|
|
111
|
+
return 0
|
|
112
|
+
if argv[0] == "lint" and len(argv) in (2, 3):
|
|
113
|
+
args = argv[1:]
|
|
114
|
+
strict = "--strict" in args
|
|
115
|
+
paths = [a for a in args if a != "--strict"]
|
|
116
|
+
if len(paths) != 1:
|
|
117
|
+
print("usage: python -m proofit lint [--strict] <runbook.md>",
|
|
118
|
+
file=sys.stderr)
|
|
119
|
+
return 2
|
|
120
|
+
return cmd_lint(paths[0], strict=strict)
|
|
121
|
+
if argv[0] == "template" and len(argv) == 1:
|
|
122
|
+
return cmd_template()
|
|
123
|
+
if argv[0] == "ledger":
|
|
124
|
+
return cmd_ledger(argv[1:])
|
|
125
|
+
print(f"usage: python -m proofit (lint <file> | template | ledger [n])", file=sys.stderr)
|
|
126
|
+
return 2
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
if __name__ == "__main__":
|
|
130
|
+
sys.exit(main())
|
proofit/checks.py
ADDED
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
"""The ten proofit rules as code.
|
|
2
|
+
|
|
3
|
+
Each check takes a parsed Runbook and returns a list of Violations.
|
|
4
|
+
Under fail-closed semantics every violation is an ERROR: any one of them
|
|
5
|
+
rejects the draft.
|
|
6
|
+
|
|
7
|
+
Implementation status per rule:
|
|
8
|
+
full check : R2, R3, R4, R6, R7, R9, R10 (mechanical structure/schema)
|
|
9
|
+
heuristic : R1, R8 (narrow phrase lists — tripwires)
|
|
10
|
+
convention : R5 (enforces the Term (definition)
|
|
11
|
+
pattern where a glossary exists;
|
|
12
|
+
detecting undefined jargon without
|
|
13
|
+
a glossary is out of scope)
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
|
|
19
|
+
from .model import Runbook, Violation
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _has_label(text: str) -> bool:
|
|
23
|
+
"""Does the line name a visible label (bold, quotes, or code)?"""
|
|
24
|
+
return bool(
|
|
25
|
+
re.search(r"\*\*.+?\*\*|\"[^\"]+\"|'[^']+'|`[^`]+`", text)
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _strip_labels(text: str) -> str:
|
|
30
|
+
text = re.sub(r"\*\*(.+?)\*\*", " ", text)
|
|
31
|
+
text = re.sub(r'"[^"]+"', " ", text)
|
|
32
|
+
text = re.sub(r"'[^']+'", " ", text)
|
|
33
|
+
text = re.sub(r"`[^`]+`", " ", text)
|
|
34
|
+
return text
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _word_in(text: str, words: list[str]) -> bool:
|
|
38
|
+
t = text.lower()
|
|
39
|
+
return any(re.search(r"\b" + re.escape(w) + r"\b", t) for w in words)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
# R1 — Remove every assumption (heuristic, narrow)
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
# Fires only on clear hits: bare unlabeled directives ("Press Continue"),
|
|
46
|
+
# and claims that something happens on its own ("is done automatically").
|
|
47
|
+
# It cannot prove completeness — a linter can't know which assumptions the
|
|
48
|
+
# author silently held. Kept narrow on purpose: under fail-closed a
|
|
49
|
+
# false positive blocks a good draft.
|
|
50
|
+
R1_BARE_DIRECTIVES = [
|
|
51
|
+
"press continue",
|
|
52
|
+
"click continue",
|
|
53
|
+
"select the option",
|
|
54
|
+
"click ok",
|
|
55
|
+
"press ok",
|
|
56
|
+
]
|
|
57
|
+
# "is generated automatically" / "are handled automatically" — a claim that
|
|
58
|
+
# something happens on its own. Deliberately requires is/are/was/were so that
|
|
59
|
+
# plain state-change descriptions ("the download begins") don't trip it.
|
|
60
|
+
R1_AUTOMATICITY_RE = re.compile(
|
|
61
|
+
r"\b(is|are|was|were)\b[^.\n]{0,40}\bautomatically\b", re.IGNORECASE
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def check_r1_assumptions(rb: Runbook) -> list[Violation]:
|
|
66
|
+
out: list[Violation] = []
|
|
67
|
+
for step in rb.steps:
|
|
68
|
+
for field_name in ("do", "look_for"):
|
|
69
|
+
line = getattr(step, field_name)
|
|
70
|
+
if not line:
|
|
71
|
+
continue
|
|
72
|
+
low = line.lower()
|
|
73
|
+
if R1_AUTOMATICITY_RE.search(line):
|
|
74
|
+
out.append(Violation(
|
|
75
|
+
rule=1,
|
|
76
|
+
location=f"Step {step.number} ({field_name})",
|
|
77
|
+
message=f'assumes automatic behavior: "{line.strip()}"',
|
|
78
|
+
fix="state the condition explicitly instead of assuming it happens on its own",
|
|
79
|
+
))
|
|
80
|
+
continue
|
|
81
|
+
if not _has_label(line):
|
|
82
|
+
for phrase in R1_BARE_DIRECTIVES:
|
|
83
|
+
if phrase in low:
|
|
84
|
+
out.append(Violation(
|
|
85
|
+
rule=1,
|
|
86
|
+
location=f"Step {step.number} ({field_name})",
|
|
87
|
+
message=f'unlabeled directive assumes the reader knows where/what: "{line.strip()}"',
|
|
88
|
+
fix="name the exact visible label, e.g. the blue **Continue** button in the bottom-right corner",
|
|
89
|
+
))
|
|
90
|
+
break
|
|
91
|
+
return out
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
# R2 — Define every requirement before starting (structure)
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
PREREQ_FIELDS: list[tuple[str, list[str]]] = [
|
|
98
|
+
("hardware", ["hardware", "computer", "laptop", "device", "machine"]),
|
|
99
|
+
("software", ["software", "browser", "app", "apps", "application", "program"]),
|
|
100
|
+
("accounts", ["account", "accounts"]),
|
|
101
|
+
("permissions", ["permission", "permissions", "admin", "access level"]),
|
|
102
|
+
("files", ["file", "files"]),
|
|
103
|
+
("internet access", ["internet", "wi-fi", "wifi", "online", "connection"]),
|
|
104
|
+
("free-tier limits", ["limit", "limits", "quota", "rate limit",
|
|
105
|
+
"per day", "per month", "per hour"]),
|
|
106
|
+
("starting state", ["starting state", "baseline", "precondition", "preconditions",
|
|
107
|
+
"starting condition"]),
|
|
108
|
+
]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def check_r2_prerequisites(rb: Runbook) -> list[Violation]:
|
|
112
|
+
out: list[Violation] = []
|
|
113
|
+
if not rb.prereq_present:
|
|
114
|
+
out.append(Violation(
|
|
115
|
+
rule=2,
|
|
116
|
+
location="document",
|
|
117
|
+
message="no Ground Rules & Prerequisites section found",
|
|
118
|
+
fix="add a Ground Rules & Prerequisites section before the first step, covering all eight required fields",
|
|
119
|
+
))
|
|
120
|
+
return out
|
|
121
|
+
kinds = [k for k, _, _ in rb.section_order]
|
|
122
|
+
if "steps" in kinds and kinds.index("prereq") > kinds.index("steps"):
|
|
123
|
+
out.append(Violation(
|
|
124
|
+
rule=2,
|
|
125
|
+
location="document",
|
|
126
|
+
message="prerequisites section comes after the steps — requirements must be defined before step 1",
|
|
127
|
+
fix="move Ground Rules & Prerequisites above Step-by-Step Directives",
|
|
128
|
+
))
|
|
129
|
+
for field_name, keywords in PREREQ_FIELDS:
|
|
130
|
+
# Free-tier limits may live in the per-service routing specs, which
|
|
131
|
+
# are part of the prerequisites section — count those too.
|
|
132
|
+
haystack = rb.prereq_text
|
|
133
|
+
if field_name == "free-tier limits":
|
|
134
|
+
haystack += " " + " ".join(s.body for s in rb.services)
|
|
135
|
+
if not _word_in(haystack, keywords):
|
|
136
|
+
out.append(Violation(
|
|
137
|
+
rule=2,
|
|
138
|
+
location="Ground Rules & Prerequisites",
|
|
139
|
+
message=f"required prerequisite field missing: {field_name}",
|
|
140
|
+
fix=f"state the {field_name} requirement explicitly (never reveal new prerequisites mid-guide)",
|
|
141
|
+
))
|
|
142
|
+
return out
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# ---------------------------------------------------------------------------
|
|
146
|
+
# R3 — One action per step (structure — the strongest check)
|
|
147
|
+
# ---------------------------------------------------------------------------
|
|
148
|
+
ACTION_VERBS = [
|
|
149
|
+
"open", "click", "press", "select", "type", "enter", "copy", "paste",
|
|
150
|
+
"save", "run", "launch", "close", "download", "upload", "install",
|
|
151
|
+
"uninstall", "delete", "create", "rename", "move", "wait", "check",
|
|
152
|
+
"read", "write", "send", "start", "stop", "restart", "log", "sign",
|
|
153
|
+
"navigate", "go", "scroll", "drag", "drop", "choose", "pick", "set",
|
|
154
|
+
"change", "fill", "submit", "confirm", "cancel", "switch", "export", "load",
|
|
155
|
+
"import", "print", "refresh",
|
|
156
|
+
]
|
|
157
|
+
_SPLIT_RE = re.compile(r"\s+and\s+|\s*,\s*then\s+|\s+then\s+|\s*;\s*")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _verb_led(fragment: str) -> bool:
|
|
161
|
+
words = re.findall(r"[A-Za-z']+", fragment)
|
|
162
|
+
if not words:
|
|
163
|
+
return False
|
|
164
|
+
# crude stemming so "clicks" matches "click" ("go" keeps its form)
|
|
165
|
+
stem = words[0].lower()
|
|
166
|
+
if stem.endswith("s") and stem[:-1] in ACTION_VERBS:
|
|
167
|
+
stem = stem[:-1]
|
|
168
|
+
return stem in ACTION_VERBS
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _count_actions(do_text: str) -> int:
|
|
172
|
+
fragments = [f for f in _SPLIT_RE.split(do_text) if f.strip()]
|
|
173
|
+
return sum(1 for f in fragments if _verb_led(f))
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def check_r3_one_action(rb: Runbook) -> list[Violation]:
|
|
177
|
+
out: list[Violation] = []
|
|
178
|
+
if not rb.steps:
|
|
179
|
+
out.append(Violation(
|
|
180
|
+
rule=3,
|
|
181
|
+
location="Step-by-Step Directives",
|
|
182
|
+
message="no steps found — the directives section is missing or empty",
|
|
183
|
+
fix="write the workflow as numbered steps, one action each",
|
|
184
|
+
))
|
|
185
|
+
return out
|
|
186
|
+
numbers = [s.number for s in rb.steps]
|
|
187
|
+
if numbers != list(range(1, len(numbers) + 1)):
|
|
188
|
+
out.append(Violation(
|
|
189
|
+
rule=3,
|
|
190
|
+
location="Step-by-Step Directives",
|
|
191
|
+
message=f"step numbers are not sequential 1..{len(numbers)}: {numbers}",
|
|
192
|
+
fix="renumber steps sequentially starting at 1",
|
|
193
|
+
))
|
|
194
|
+
for step in rb.steps:
|
|
195
|
+
if not step.do.strip():
|
|
196
|
+
out.append(Violation(
|
|
197
|
+
rule=3,
|
|
198
|
+
location=f"Step {step.number}",
|
|
199
|
+
message="step has no Do: field — zero actions is not one action",
|
|
200
|
+
fix="write exactly one action in a Do: field",
|
|
201
|
+
))
|
|
202
|
+
continue
|
|
203
|
+
n = _count_actions(step.do)
|
|
204
|
+
if n > 1:
|
|
205
|
+
out.append(Violation(
|
|
206
|
+
rule=3,
|
|
207
|
+
location=f"Step {step.number}",
|
|
208
|
+
message=f'{n} actions in one Do: "{step.do.strip()}"',
|
|
209
|
+
fix="split into separate steps, one action each",
|
|
210
|
+
))
|
|
211
|
+
return out
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# ---------------------------------------------------------------------------
|
|
215
|
+
# R4 — Use concrete references (phrase + format)
|
|
216
|
+
# ---------------------------------------------------------------------------
|
|
217
|
+
# Fires only on BARE nouns — "the button", "the dialog" — the skill's own
|
|
218
|
+
# bad examples. A noun with a qualifier ("a confirmation dialog", "the VS
|
|
219
|
+
# Code window", "the blue Publish button") names something distinguishable
|
|
220
|
+
# and does not fire. "window"/"screen" are excluded: they almost always
|
|
221
|
+
# carry a product name ("the Settings window").
|
|
222
|
+
UI_NOUNS = ["button", "link", "menu", "tab", "dialog", "field", "icon",
|
|
223
|
+
"dropdown", "checkbox", "option"]
|
|
224
|
+
_BARE_UI_RE = re.compile(
|
|
225
|
+
r"\b(the|a|an)\s+(button|link|menu|tab|dialog|field|icon|dropdown|checkbox|option)\b",
|
|
226
|
+
re.IGNORECASE,
|
|
227
|
+
)
|
|
228
|
+
_QUALIFIED_UI_RE = re.compile(
|
|
229
|
+
r"\b(the|a|an)\s+[A-Za-z]+\s+(button|link|menu|tab|dialog|field|icon|dropdown|checkbox|option)\b",
|
|
230
|
+
re.IGNORECASE,
|
|
231
|
+
)
|
|
232
|
+
R4_VAGUE = ["select the file", "open the file"]
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def check_r4_concrete_refs(rb: Runbook) -> list[Violation]:
|
|
236
|
+
out: list[Violation] = []
|
|
237
|
+
for step in rb.steps:
|
|
238
|
+
for field_name in ("do", "look_for"):
|
|
239
|
+
line = getattr(step, field_name)
|
|
240
|
+
if not line:
|
|
241
|
+
continue
|
|
242
|
+
low = line.lower()
|
|
243
|
+
if _has_label(line):
|
|
244
|
+
continue
|
|
245
|
+
if _BARE_UI_RE.search(line) and not _QUALIFIED_UI_RE.search(line):
|
|
246
|
+
out.append(Violation(
|
|
247
|
+
rule=4,
|
|
248
|
+
location=f"Step {step.number} ({field_name})",
|
|
249
|
+
message=f'UI element named without a visible label: "{line.strip()}"',
|
|
250
|
+
fix="quote or bold the exact visible label, e.g. the **Save** button",
|
|
251
|
+
))
|
|
252
|
+
continue
|
|
253
|
+
for phrase in R4_VAGUE:
|
|
254
|
+
if phrase in low and "named" not in low:
|
|
255
|
+
out.append(Violation(
|
|
256
|
+
rule=4,
|
|
257
|
+
location=f"Step {step.number} ({field_name})",
|
|
258
|
+
message=f'vague reference with no label: "{line.strip()}"',
|
|
259
|
+
fix="name the exact item, e.g. the file named **report.xlsx**",
|
|
260
|
+
))
|
|
261
|
+
break
|
|
262
|
+
return out
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
# ---------------------------------------------------------------------------
|
|
266
|
+
# R5 — Explain unavoidable technical terms (convention)
|
|
267
|
+
# ---------------------------------------------------------------------------
|
|
268
|
+
# Enforces the `Term (definition)` pattern where a glossary exists.
|
|
269
|
+
# Detecting undefined jargon WITHOUT a glossary needs a glossary or a
|
|
270
|
+
# model — that judgment is explicitly out of scope (see README).
|
|
271
|
+
TERM_DEF_RE = re.compile(r"^\s*[-*]?\s*.+\([^)]+\)\s*$")
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def check_r5_terms(rb: Runbook) -> list[Violation]:
|
|
275
|
+
out: list[Violation] = []
|
|
276
|
+
if not rb.glossary_present:
|
|
277
|
+
return out
|
|
278
|
+
for i, line in enumerate(rb.glossary_text.splitlines(), start=1):
|
|
279
|
+
s = line.strip()
|
|
280
|
+
if not s or s.startswith("#"):
|
|
281
|
+
continue
|
|
282
|
+
if not TERM_DEF_RE.match(s):
|
|
283
|
+
out.append(Violation(
|
|
284
|
+
rule=5,
|
|
285
|
+
location=f"glossary (line {i})",
|
|
286
|
+
message=f'term without an inline definition: "{s}"',
|
|
287
|
+
fix="use the Term (definition) pattern, e.g. Repository (a folder that stores the project's files and history)",
|
|
288
|
+
))
|
|
289
|
+
return out
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
# ---------------------------------------------------------------------------
|
|
293
|
+
# R6 — Verification after every milestone (structure)
|
|
294
|
+
# ---------------------------------------------------------------------------
|
|
295
|
+
OBSERVABLE = ["display", "displays", "appear", "appears", "exist", "exists",
|
|
296
|
+
"contain", "contains", "show", "shows", "return", "returns",
|
|
297
|
+
"match", "matches", "visible", "checkmark", "present",
|
|
298
|
+
"created", "saved", "opens"]
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def check_r6_verification(rb: Runbook) -> list[Violation]:
|
|
302
|
+
out: list[Violation] = []
|
|
303
|
+
for step in rb.steps:
|
|
304
|
+
if not step.verify.strip():
|
|
305
|
+
out.append(Violation(
|
|
306
|
+
rule=6,
|
|
307
|
+
location=f"Step {step.number}",
|
|
308
|
+
message="step ends without a Verify: block",
|
|
309
|
+
fix="describe exactly what should be observed, e.g. Verify: the file now exists at ~/backup/",
|
|
310
|
+
))
|
|
311
|
+
elif not _word_in(step.verify, OBSERVABLE):
|
|
312
|
+
out.append(Violation(
|
|
313
|
+
rule=6,
|
|
314
|
+
location=f"Step {step.number} (Verify)",
|
|
315
|
+
message=f'verification is not objectively checkable: "{step.verify.strip()}"',
|
|
316
|
+
fix="describe something observable — what the screen displays, what exists, what the output contains",
|
|
317
|
+
))
|
|
318
|
+
return out
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
# ---------------------------------------------------------------------------
|
|
322
|
+
# R7 — Include immediate recovery (structure)
|
|
323
|
+
# ---------------------------------------------------------------------------
|
|
324
|
+
def check_r7_recovery(rb: Runbook) -> list[Violation]:
|
|
325
|
+
out: list[Violation] = []
|
|
326
|
+
for step in rb.steps:
|
|
327
|
+
if not step.if_wrong.strip():
|
|
328
|
+
out.append(Violation(
|
|
329
|
+
rule=7,
|
|
330
|
+
location=f"Step {step.number}",
|
|
331
|
+
message="step has no If wrong: block — the most likely mistake is unstated",
|
|
332
|
+
fix="add If wrong: describing the most likely mistake for this step",
|
|
333
|
+
))
|
|
334
|
+
if not step.fix.strip():
|
|
335
|
+
out.append(Violation(
|
|
336
|
+
rule=7,
|
|
337
|
+
location=f"Step {step.number}",
|
|
338
|
+
message="step has no Fix: block — no recovery path",
|
|
339
|
+
fix="add Fix: with the shortest recovery path (resume the guide, don't restart, when possible)",
|
|
340
|
+
))
|
|
341
|
+
return out
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
# ---------------------------------------------------------------------------
|
|
345
|
+
# R8 — Never skip state changes (heuristic, narrow)
|
|
346
|
+
# ---------------------------------------------------------------------------
|
|
347
|
+
# A linter cannot know what state changes a step *causes* — that needs the
|
|
348
|
+
# author or a model. This is a tripwire: when the Do: line starts with a
|
|
349
|
+
# state-changing verb, the Look for:/Verify: text must describe the resulting
|
|
350
|
+
# state in transition language. Narrow by design.
|
|
351
|
+
STATE_VERBS = ["open", "close", "launch", "start", "restart", "install",
|
|
352
|
+
"uninstall", "delete", "download", "upload", "submit",
|
|
353
|
+
"refresh", "navigate", "log", "sign"]
|
|
354
|
+
TRANSITIONS = ["opens", "appears", "closes", "begins", "starts", "dialog",
|
|
355
|
+
"new tab", "window", "confirmation", "downloads", "downloaded",
|
|
356
|
+
"installed", "deleted", "submitted", "refreshed", "logged in",
|
|
357
|
+
"logged out", "signed in", "signed out"]
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def check_r8_state_changes(rb: Runbook) -> list[Violation]:
|
|
361
|
+
out: list[Violation] = []
|
|
362
|
+
for step in rb.steps:
|
|
363
|
+
words = re.findall(r"[A-Za-z']+", step.do)
|
|
364
|
+
if not words:
|
|
365
|
+
continue
|
|
366
|
+
first = words[0].lower()
|
|
367
|
+
if first not in STATE_VERBS:
|
|
368
|
+
continue
|
|
369
|
+
observed = (step.look_for + " " + step.verify).lower()
|
|
370
|
+
if not any(t in observed for t in TRANSITIONS):
|
|
371
|
+
out.append(Violation(
|
|
372
|
+
rule=8,
|
|
373
|
+
location=f"Step {step.number}",
|
|
374
|
+
message=f'state-changing action with no described state change: "{step.do.strip()}"',
|
|
375
|
+
fix="describe what the software visibly does — a new tab opens, a dialog appears, the window closes",
|
|
376
|
+
))
|
|
377
|
+
return out
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
# ---------------------------------------------------------------------------
|
|
381
|
+
# R9 — Specify routing decisions upfront (schema)
|
|
382
|
+
# ---------------------------------------------------------------------------
|
|
383
|
+
SERVICE_ITEMS: list[tuple[str, list[str]]] = [
|
|
384
|
+
("when to use", ["when to use", "use when", "trigger", "condition", "route to", "use for"]),
|
|
385
|
+
("free-tier limits", ["limit", "limits", "quota", "rate limit", "context window"]),
|
|
386
|
+
("verbatim prompt", ["prompt"]),
|
|
387
|
+
("expected output format", ["output format", "expected output", "schema", "response format"]),
|
|
388
|
+
]
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def check_r9_routing(rb: Runbook) -> list[Violation]:
|
|
392
|
+
out: list[Violation] = []
|
|
393
|
+
if not rb.services:
|
|
394
|
+
return out # single-service workflow: routing spec is vacuous
|
|
395
|
+
for svc in rb.services:
|
|
396
|
+
for item_name, keywords in SERVICE_ITEMS:
|
|
397
|
+
if not _word_in(svc.body, keywords):
|
|
398
|
+
out.append(Violation(
|
|
399
|
+
rule=9,
|
|
400
|
+
location=f"Routing / {svc.name}",
|
|
401
|
+
message=f"service spec missing: {item_name}",
|
|
402
|
+
fix=f"specify {item_name} for {svc.name} — no improvisation mid-workflow",
|
|
403
|
+
))
|
|
404
|
+
if not _word_in(rb.prereq_text, ["rate limit handling", "when hitting", "quota exhaust"]):
|
|
405
|
+
out.append(Violation(
|
|
406
|
+
rule=9,
|
|
407
|
+
location="Routing Architecture",
|
|
408
|
+
message="no rate-limit handling — what to do when a free-tier limit is hit is unstated",
|
|
409
|
+
fix="add rate limit handling: wait time, fallback service, or graceful degradation",
|
|
410
|
+
))
|
|
411
|
+
if not _word_in(rb.prereq_text, ["error path", "error paths", "if any service fails",
|
|
412
|
+
"unexpected data", "service fails"]):
|
|
413
|
+
out.append(Violation(
|
|
414
|
+
rule=9,
|
|
415
|
+
location="Routing Architecture",
|
|
416
|
+
message="no error paths — what to do if a service fails or returns unexpected data is unstated",
|
|
417
|
+
fix="add error paths per service",
|
|
418
|
+
))
|
|
419
|
+
return out
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
# ---------------------------------------------------------------------------
|
|
423
|
+
# R10 — Finish with full validation (structure)
|
|
424
|
+
# ---------------------------------------------------------------------------
|
|
425
|
+
def check_r10_validation(rb: Runbook) -> list[Violation]:
|
|
426
|
+
out: list[Violation] = []
|
|
427
|
+
if not rb.validation_present:
|
|
428
|
+
out.append(Violation(
|
|
429
|
+
rule=10,
|
|
430
|
+
location="document",
|
|
431
|
+
message="no Validation & Closure section — the document never describes the completed state",
|
|
432
|
+
fix="end with Validation & Closure: a Success Condition plus consistency checkpoints",
|
|
433
|
+
))
|
|
434
|
+
return out
|
|
435
|
+
kinds = [k for k, _, _ in rb.section_order]
|
|
436
|
+
# A trailing glossary is fine — the rule is "never end on an action".
|
|
437
|
+
if kinds and kinds[-1] not in ("validation", "glossary"):
|
|
438
|
+
out.append(Violation(
|
|
439
|
+
rule=10,
|
|
440
|
+
location="document",
|
|
441
|
+
message="document does not end with Validation & Closure — it ends on an action",
|
|
442
|
+
fix="move Validation & Closure to the end; never end with the final action",
|
|
443
|
+
))
|
|
444
|
+
# A Step N heading placed after the validation section means the
|
|
445
|
+
# document ends on an action even if a validation section exists.
|
|
446
|
+
val_pos = None
|
|
447
|
+
for m in re.finditer(r"^#{1,6}\s+.*$", rb.raw, re.M):
|
|
448
|
+
if re.search(r"valid|closure|success condition|consistency", m.group(0), re.I):
|
|
449
|
+
val_pos = m.start()
|
|
450
|
+
if val_pos is not None:
|
|
451
|
+
for m in re.finditer(r"^#{1,6}\s+step\s+\d+", rb.raw, re.M | re.I):
|
|
452
|
+
if m.start() > val_pos:
|
|
453
|
+
out.append(Violation(
|
|
454
|
+
rule=10,
|
|
455
|
+
location="document",
|
|
456
|
+
message="a step appears after Validation & Closure — the document ends on an action",
|
|
457
|
+
fix="move all steps above Validation & Closure",
|
|
458
|
+
))
|
|
459
|
+
break
|
|
460
|
+
if not _word_in(rb.validation_text, ["success condition"]):
|
|
461
|
+
out.append(Violation(
|
|
462
|
+
rule=10,
|
|
463
|
+
location="Validation & Closure",
|
|
464
|
+
message="no Success Condition — the exact final state is undescribed",
|
|
465
|
+
fix="describe the exact final state so success is certain, not hoped for",
|
|
466
|
+
))
|
|
467
|
+
has_checklist = bool(re.search(r"^\s*-\s*\[[ xX]\]", rb.validation_text, re.M))
|
|
468
|
+
has_list = bool(re.search(r"^\s*(?:-\s+|\*\s+|\d+\.\s+)\S", rb.validation_text, re.M))
|
|
469
|
+
if not has_checklist and not has_list:
|
|
470
|
+
out.append(Violation(
|
|
471
|
+
rule=10,
|
|
472
|
+
location="Validation & Closure",
|
|
473
|
+
message="no consistency checklist — nothing to check the workflow against",
|
|
474
|
+
fix="add consistency checkpoints: inputs met spec, prompts correct, outputs matched format",
|
|
475
|
+
))
|
|
476
|
+
return out
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
ALL_CHECKS = [
|
|
480
|
+
check_r1_assumptions,
|
|
481
|
+
check_r2_prerequisites,
|
|
482
|
+
check_r3_one_action,
|
|
483
|
+
check_r4_concrete_refs,
|
|
484
|
+
check_r5_terms,
|
|
485
|
+
check_r6_verification,
|
|
486
|
+
check_r7_recovery,
|
|
487
|
+
check_r8_state_changes,
|
|
488
|
+
check_r9_routing,
|
|
489
|
+
check_r10_validation,
|
|
490
|
+
]
|
proofit/ledger.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Append-only local ledger. Vendored per tool; stdlib only.
|
|
2
|
+
|
|
3
|
+
CANONICAL COPY: edit this file, then re-vendor with vendor.sh.
|
|
4
|
+
Do not edit the vendored copies in place.
|
|
5
|
+
|
|
6
|
+
Privacy rule (non-negotiable): the ledger NEVER stores raw input text.
|
|
7
|
+
Records carry SHA-256 digests of inputs plus structural summaries
|
|
8
|
+
(counts, verdicts, deltas). What happened, and what kind — never the words.
|
|
9
|
+
|
|
10
|
+
Location: ~/.frontface/<tool>/ledger.jsonl
|
|
11
|
+
override: FRONTFACE_LEDGER_DIR=<dir> (ledgers go to <dir>/<tool>/)
|
|
12
|
+
disable: FRONTFACE_NO_LEDGER=1 (record() becomes a silent no-op)
|
|
13
|
+
|
|
14
|
+
The ledger must never break the tool it serves: record() swallows OSError
|
|
15
|
+
and returns False instead of raising.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import hashlib
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import sys
|
|
24
|
+
from datetime import datetime, timezone
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
import fcntl
|
|
28
|
+
except ImportError: # pragma: no cover — Windows has no fcntl
|
|
29
|
+
fcntl = None # type: ignore[assignment]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _base_dir() -> str:
|
|
33
|
+
return os.environ.get("FRONTFACE_LEDGER_DIR") or os.path.join(
|
|
34
|
+
os.path.expanduser("~"), ".frontface"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _disabled() -> bool:
|
|
39
|
+
return os.environ.get("FRONTFACE_NO_LEDGER") == "1"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Ledger:
|
|
43
|
+
"""One JSONL ledger per tool. Cheap to construct; construct per use."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, tool: str) -> None:
|
|
46
|
+
self.tool = tool
|
|
47
|
+
self.dir = os.path.join(_base_dir(), tool)
|
|
48
|
+
self.path = os.path.join(self.dir, "ledger.jsonl")
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def version(self) -> str:
|
|
52
|
+
"""The owning package's __version__, resolved lazily."""
|
|
53
|
+
try:
|
|
54
|
+
pkg = sys.modules.get(__package__ or "")
|
|
55
|
+
return str(getattr(pkg, "__version__", "unknown"))
|
|
56
|
+
except Exception:
|
|
57
|
+
return "unknown"
|
|
58
|
+
|
|
59
|
+
@staticmethod
|
|
60
|
+
def digest(text: str) -> str:
|
|
61
|
+
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
62
|
+
|
|
63
|
+
def record(
|
|
64
|
+
self,
|
|
65
|
+
event: str,
|
|
66
|
+
summary: dict,
|
|
67
|
+
*,
|
|
68
|
+
input_text: str | None = None,
|
|
69
|
+
) -> bool:
|
|
70
|
+
"""Append one record. Returns False when disabled or on I/O error.
|
|
71
|
+
|
|
72
|
+
Never raises. Never stores raw input text — only its digest.
|
|
73
|
+
"""
|
|
74
|
+
if _disabled():
|
|
75
|
+
return False
|
|
76
|
+
rec: dict = {
|
|
77
|
+
"ts": datetime.now(timezone.utc).isoformat(),
|
|
78
|
+
"tool": self.tool,
|
|
79
|
+
"tool_version": self.version,
|
|
80
|
+
"event": event,
|
|
81
|
+
"summary": summary,
|
|
82
|
+
}
|
|
83
|
+
if input_text is not None:
|
|
84
|
+
rec["input_digest"] = self.digest(input_text)
|
|
85
|
+
try:
|
|
86
|
+
os.makedirs(self.dir, exist_ok=True)
|
|
87
|
+
with open(self.path, "a", encoding="utf-8") as f:
|
|
88
|
+
if fcntl is not None:
|
|
89
|
+
fcntl.flock(f, fcntl.LOCK_EX)
|
|
90
|
+
try:
|
|
91
|
+
f.write(json.dumps(rec) + "\n")
|
|
92
|
+
f.flush()
|
|
93
|
+
os.fsync(f.fileno())
|
|
94
|
+
finally:
|
|
95
|
+
fcntl.flock(f, fcntl.LOCK_UN)
|
|
96
|
+
else:
|
|
97
|
+
f.write(json.dumps(rec) + "\n")
|
|
98
|
+
except OSError:
|
|
99
|
+
return False
|
|
100
|
+
return True
|
|
101
|
+
|
|
102
|
+
def read(self, limit: int | None = None) -> list[dict]:
|
|
103
|
+
"""All records, oldest first. Empty list when there's no ledger."""
|
|
104
|
+
try:
|
|
105
|
+
with open(self.path, encoding="utf-8") as f:
|
|
106
|
+
lines = f.read().splitlines()
|
|
107
|
+
except OSError:
|
|
108
|
+
return []
|
|
109
|
+
recs = []
|
|
110
|
+
for line in lines:
|
|
111
|
+
line = line.strip()
|
|
112
|
+
if line:
|
|
113
|
+
try:
|
|
114
|
+
recs.append(json.loads(line))
|
|
115
|
+
except ValueError:
|
|
116
|
+
continue # never let a corrupt line kill the read
|
|
117
|
+
return recs[-limit:] if limit else recs
|
|
118
|
+
|
|
119
|
+
def summary(self) -> dict:
|
|
120
|
+
"""Counts by event plus first/last timestamps."""
|
|
121
|
+
recs = self.read()
|
|
122
|
+
by_event: dict[str, int] = {}
|
|
123
|
+
for r in recs:
|
|
124
|
+
ev = str(r.get("event", "?"))
|
|
125
|
+
by_event[ev] = by_event.get(ev, 0) + 1
|
|
126
|
+
return {
|
|
127
|
+
"tool": self.tool,
|
|
128
|
+
"records": len(recs),
|
|
129
|
+
"events": by_event,
|
|
130
|
+
"first": recs[0]["ts"] if recs else None,
|
|
131
|
+
"last": recs[-1]["ts"] if recs else None,
|
|
132
|
+
}
|
proofit/linter.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Orchestrator: parse a runbook draft, run all ten rule checks, collect violations."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from .checks import ALL_CHECKS
|
|
5
|
+
from .model import LintResult
|
|
6
|
+
from .parser import parse
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def lint_text(markdown: str, *, strict: bool = False) -> LintResult:
|
|
10
|
+
runbook = parse(markdown)
|
|
11
|
+
violations = []
|
|
12
|
+
for check in ALL_CHECKS:
|
|
13
|
+
violations.extend(check(runbook))
|
|
14
|
+
# deterministic order: rule number, then location
|
|
15
|
+
violations.sort(key=lambda v: (v.rule, v.location))
|
|
16
|
+
return LintResult(violations=violations, strict=strict)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def lint_file(path: str, *, strict: bool = False) -> LintResult:
|
|
20
|
+
with open(path, encoding="utf-8") as f:
|
|
21
|
+
return lint_text(f.read(), strict=strict)
|
proofit/model.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Core data model for the proofit runbook linter."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class Step:
|
|
9
|
+
number: int
|
|
10
|
+
name: str
|
|
11
|
+
do: str = ""
|
|
12
|
+
look_for: str = ""
|
|
13
|
+
verify: str = ""
|
|
14
|
+
if_wrong: str = ""
|
|
15
|
+
fix: str = ""
|
|
16
|
+
line_no: int = 0
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class ServiceSpec:
|
|
21
|
+
name: str
|
|
22
|
+
body: str = ""
|
|
23
|
+
line_no: int = 0
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Runbook:
|
|
28
|
+
"""A runbook draft parsed from Markdown following the proofit template shape."""
|
|
29
|
+
|
|
30
|
+
raw: str = ""
|
|
31
|
+
# (kind, title, line_no) for top-level sections, in document order.
|
|
32
|
+
# kind is one of: prereq, steps, validation, glossary, other
|
|
33
|
+
section_order: list = field(default_factory=list)
|
|
34
|
+
prereq_text: str = ""
|
|
35
|
+
prereq_present: bool = False
|
|
36
|
+
steps: list = field(default_factory=list) # list[Step]
|
|
37
|
+
steps_present: bool = False
|
|
38
|
+
validation_text: str = ""
|
|
39
|
+
validation_present: bool = False
|
|
40
|
+
glossary_text: str = ""
|
|
41
|
+
glossary_present: bool = False
|
|
42
|
+
services: list = field(default_factory=list) # list[ServiceSpec]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# PARAM-PROOFIT-001: rule -> severity. Structural rules are ERROR
|
|
46
|
+
# (fail-closed); heuristic (R1, R8) and convention (R5) are WARNING
|
|
47
|
+
# (listed, never fail the draft unless --strict).
|
|
48
|
+
ERROR = "ERROR"
|
|
49
|
+
WARNING = "WARNING"
|
|
50
|
+
SEVERITY_BY_RULE = {
|
|
51
|
+
1: WARNING, # heuristic: assumptions
|
|
52
|
+
2: ERROR, # structural: prerequisites
|
|
53
|
+
3: ERROR, # structural: one action
|
|
54
|
+
4: ERROR, # structural: concrete refs
|
|
55
|
+
5: WARNING, # convention: terms
|
|
56
|
+
6: ERROR, # structural: verification
|
|
57
|
+
7: ERROR, # structural: recovery
|
|
58
|
+
8: WARNING, # heuristic: state changes
|
|
59
|
+
9: ERROR, # structural: routing
|
|
60
|
+
10: ERROR, # structural: validation
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class Violation:
|
|
66
|
+
rule: int
|
|
67
|
+
location: str
|
|
68
|
+
message: str
|
|
69
|
+
fix: str
|
|
70
|
+
# Severity is derived from the rule, not passed — the map above is
|
|
71
|
+
# the adjudicated spec. An explicit value wins if ever given.
|
|
72
|
+
severity: str = ""
|
|
73
|
+
|
|
74
|
+
def __post_init__(self) -> None:
|
|
75
|
+
if not self.severity:
|
|
76
|
+
self.severity = SEVERITY_BY_RULE.get(self.rule, ERROR)
|
|
77
|
+
|
|
78
|
+
def render(self) -> str:
|
|
79
|
+
lines = [f"RULE {self.rule} [{self.location}] {self.severity} \u2014 {self.message}"]
|
|
80
|
+
if self.fix:
|
|
81
|
+
lines.append(f" fix: {self.fix}")
|
|
82
|
+
return "\n".join(lines)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class LintResult:
|
|
87
|
+
violations: list = field(default_factory=list) # list[Violation]
|
|
88
|
+
# PARAM-PROOFIT-001: strict mode restores the old fail-on-anything
|
|
89
|
+
# behavior (the --strict CLI flag). Default is fail-closed on ERRORs.
|
|
90
|
+
strict: bool = False
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def errors(self) -> list:
|
|
94
|
+
return [v for v in self.violations if v.severity == ERROR]
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def warnings(self) -> list:
|
|
98
|
+
return [v for v in self.violations if v.severity == WARNING]
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def passed(self) -> bool:
|
|
102
|
+
if self.strict:
|
|
103
|
+
return not self.violations
|
|
104
|
+
return not self.errors
|
proofit/parser.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Template-shaped Markdown parser for runbook drafts.
|
|
2
|
+
|
|
3
|
+
This is not a general Markdown parser. It understands exactly one document
|
|
4
|
+
shape: the proofit runbook template (see templates/runbook-template.md),
|
|
5
|
+
which mirrors the skill's own Output Format section:
|
|
6
|
+
|
|
7
|
+
Ground Rules & Prerequisites / Step-by-Step Directives / Validation & Closure
|
|
8
|
+
|
|
9
|
+
Steps are `### Step N: name` headings with **Do:** / **Look for:** /
|
|
10
|
+
**Verify:** / **If wrong:** / **Fix:** fields. Service routing specs are
|
|
11
|
+
`### <service name>` headings inside the prerequisites section.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
|
|
17
|
+
from .model import Runbook, ServiceSpec, Step
|
|
18
|
+
|
|
19
|
+
HEADING_RE = re.compile(r"^(#{1,6})\s+(.*?)\s*$")
|
|
20
|
+
FIELD_RE = re.compile(
|
|
21
|
+
r"^\s*\*{0,2}(Do|Look for|Verify|If wrong|Fix)\s*:\*{0,2}\s*(.*)$",
|
|
22
|
+
re.IGNORECASE,
|
|
23
|
+
)
|
|
24
|
+
STEP_RE = re.compile(r"^step\s+(\d+)\s*:?\s*(.*)$", re.IGNORECASE)
|
|
25
|
+
|
|
26
|
+
# Subsection headings inside Ground Rules & Prerequisites that are NOT services.
|
|
27
|
+
KNOWN_PREREQ_SUBSECTIONS = [
|
|
28
|
+
r"required baseline",
|
|
29
|
+
r"starting state",
|
|
30
|
+
r"precondition",
|
|
31
|
+
r"routing architecture",
|
|
32
|
+
r"input spec",
|
|
33
|
+
r"rate limit",
|
|
34
|
+
r"error path",
|
|
35
|
+
r"needed tools",
|
|
36
|
+
r"required tools",
|
|
37
|
+
r"needed items",
|
|
38
|
+
r"expected outcome",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def classify_section(title: str) -> str:
|
|
43
|
+
t = title.lower()
|
|
44
|
+
if re.search(r"prereq|ground rules", t):
|
|
45
|
+
return "prereq"
|
|
46
|
+
if re.search(r"step.by.step|directives", t):
|
|
47
|
+
return "steps"
|
|
48
|
+
if re.search(r"valid|closure|success condition|consistency", t):
|
|
49
|
+
return "validation"
|
|
50
|
+
if re.search(r"glossar|\bterms\b|definitions", t):
|
|
51
|
+
return "glossary"
|
|
52
|
+
return "other"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _is_known_prereq_subsection(title: str) -> bool:
|
|
56
|
+
t = title.lower()
|
|
57
|
+
return any(re.search(p, t) for p in KNOWN_PREREQ_SUBSECTIONS)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _is_step_heading(title: str) -> bool:
|
|
61
|
+
return bool(STEP_RE.match(title.strip()))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def parse(text: str) -> Runbook:
|
|
65
|
+
rb = Runbook(raw=text)
|
|
66
|
+
lines = text.splitlines()
|
|
67
|
+
|
|
68
|
+
current_section: str | None = None
|
|
69
|
+
current_step: Step | None = None
|
|
70
|
+
current_field: str | None = None
|
|
71
|
+
current_service: ServiceSpec | None = None
|
|
72
|
+
# raw-text accumulators per top-level section
|
|
73
|
+
section_texts: dict[str, list[str]] = {}
|
|
74
|
+
|
|
75
|
+
def flush_step():
|
|
76
|
+
nonlocal current_step, current_field
|
|
77
|
+
if current_step is not None:
|
|
78
|
+
rb.steps.append(current_step)
|
|
79
|
+
current_step = None
|
|
80
|
+
current_field = None
|
|
81
|
+
|
|
82
|
+
def flush_service():
|
|
83
|
+
nonlocal current_service
|
|
84
|
+
if current_service is not None:
|
|
85
|
+
rb.services.append(current_service)
|
|
86
|
+
current_service = None
|
|
87
|
+
|
|
88
|
+
for i, line in enumerate(lines, start=1):
|
|
89
|
+
m = HEADING_RE.match(line)
|
|
90
|
+
if m:
|
|
91
|
+
level = len(m.group(1))
|
|
92
|
+
title = m.group(2).strip()
|
|
93
|
+
flush_step()
|
|
94
|
+
flush_service()
|
|
95
|
+
current_field = None
|
|
96
|
+
if level <= 2:
|
|
97
|
+
current_section = classify_section(title)
|
|
98
|
+
rb.section_order.append((current_section, title, i))
|
|
99
|
+
section_texts.setdefault(current_section, [])
|
|
100
|
+
elif current_section == "steps" and _is_step_heading(title):
|
|
101
|
+
sm = STEP_RE.match(title.strip())
|
|
102
|
+
current_step = Step(
|
|
103
|
+
number=int(sm.group(1)),
|
|
104
|
+
name=sm.group(2).strip(),
|
|
105
|
+
line_no=i,
|
|
106
|
+
)
|
|
107
|
+
rb.steps_present = True
|
|
108
|
+
elif current_section in ("prereq", "glossary"):
|
|
109
|
+
if _is_known_prereq_subsection(title):
|
|
110
|
+
current_section = "prereq"
|
|
111
|
+
elif re.search(r"glossar|\bterms\b|definitions", title, re.I):
|
|
112
|
+
current_section = "glossary"
|
|
113
|
+
rb.section_order.append(("glossary", title, i))
|
|
114
|
+
section_texts.setdefault("glossary", [])
|
|
115
|
+
elif current_section == "prereq":
|
|
116
|
+
# A named service routing block.
|
|
117
|
+
current_service = ServiceSpec(name=title, line_no=i)
|
|
118
|
+
# record raw text under the current top-level section
|
|
119
|
+
if current_section:
|
|
120
|
+
section_texts.setdefault(current_section, []).append(line)
|
|
121
|
+
continue
|
|
122
|
+
|
|
123
|
+
if current_step is not None:
|
|
124
|
+
fm = FIELD_RE.match(line)
|
|
125
|
+
if fm:
|
|
126
|
+
fname = fm.group(1).lower().replace(" ", "_")
|
|
127
|
+
current_field = fname
|
|
128
|
+
setattr(current_step, fname, fm.group(2).strip())
|
|
129
|
+
continue
|
|
130
|
+
if current_field and line.strip():
|
|
131
|
+
# continuation of the current field
|
|
132
|
+
prev = getattr(current_step, current_field)
|
|
133
|
+
setattr(current_step, current_field, (prev + " " + line.strip()).strip())
|
|
134
|
+
continue
|
|
135
|
+
continue
|
|
136
|
+
|
|
137
|
+
if current_service is not None:
|
|
138
|
+
current_service.body += line + "\n"
|
|
139
|
+
continue
|
|
140
|
+
|
|
141
|
+
if current_section:
|
|
142
|
+
section_texts.setdefault(current_section, []).append(line)
|
|
143
|
+
|
|
144
|
+
flush_step()
|
|
145
|
+
flush_service()
|
|
146
|
+
|
|
147
|
+
rb.prereq_text = "\n".join(section_texts.get("prereq", []))
|
|
148
|
+
rb.prereq_present = any(k == "prereq" for k, _, _ in rb.section_order)
|
|
149
|
+
rb.validation_text = "\n".join(section_texts.get("validation", []))
|
|
150
|
+
rb.validation_present = any(k == "validation" for k, _, _ in rb.section_order)
|
|
151
|
+
rb.glossary_text = "\n".join(section_texts.get("glossary", []))
|
|
152
|
+
rb.glossary_present = any(k == "glossary" for k, _, _ in rb.section_order)
|
|
153
|
+
return rb
|