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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ proofit = proofit.__main__:main
@@ -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