surf-cli 0.7.1__tar.gz → 0.8.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {surf_cli-0.7.1 → surf_cli-0.8.0}/.agents/skills/surf/SKILL.md +2 -1
- {surf_cli-0.7.1 → surf_cli-0.8.0}/.agents/skills/surf/references/about.md +1 -1
- {surf_cli-0.7.1 → surf_cli-0.8.0}/.agents/skills/surf/references/usage.md +18 -8
- {surf_cli-0.7.1 → surf_cli-0.8.0}/.claude-plugin/marketplace.json +2 -2
- {surf_cli-0.7.1 → surf_cli-0.8.0}/CONTRIBUTING.md +4 -3
- {surf_cli-0.7.1 → surf_cli-0.8.0}/PKG-INFO +9 -3
- {surf_cli-0.7.1 → surf_cli-0.8.0}/README.md +8 -2
- {surf_cli-0.7.1 → surf_cli-0.8.0}/flake.nix +1 -1
- {surf_cli-0.7.1 → surf_cli-0.8.0}/plugins/surf/.claude-plugin/plugin.json +1 -1
- {surf_cli-0.7.1 → surf_cli-0.8.0}/plugins/surf/plugin.json +1 -1
- {surf_cli-0.7.1 → surf_cli-0.8.0}/pyproject.toml +9 -1
- surf_cli-0.8.0/scripts/spec-coverage.py +193 -0
- surf_cli-0.8.0/spec/README.md +26 -0
- surf_cli-0.8.0/spec/features/command-line.md +53 -0
- surf_cli-0.8.0/spec/features/errors-and-exit-codes.md +67 -0
- surf_cli-0.8.0/spec/features/file-map.md +96 -0
- surf_cli-0.8.0/spec/features/filter-by-frontmatter.md +188 -0
- surf_cli-0.8.0/spec/features/frontmatter-grammar.md +55 -0
- surf_cli-0.8.0/spec/features/frontmatter-only.md +74 -0
- surf_cli-0.8.0/spec/features/heading-tree.md +96 -0
- surf_cli-0.8.0/spec/features/link-targets.md +61 -0
- surf_cli-0.8.0/spec/features/multiple-files.md +168 -0
- surf_cli-0.8.0/spec/features/no-structural-index.md +88 -0
- surf_cli-0.8.0/spec/features/pdf-addressing.md +89 -0
- surf_cli-0.8.0/spec/features/section-extraction.md +166 -0
- surf_cli-0.8.0/spec/features/tex-addressing.md +178 -0
- surf_cli-0.8.0/spec/features/what-counts-as-a-heading.md +127 -0
- {surf_cli-0.7.1 → surf_cli-0.8.0}/src/surf/__init__.py +1 -1
- {surf_cli-0.7.1 → surf_cli-0.8.0}/src/surf/adapters.py +7 -5
- {surf_cli-0.7.1 → surf_cli-0.8.0}/src/surf/logic.py +426 -0
- {surf_cli-0.7.1 → surf_cli-0.8.0}/src/surf/models.py +37 -1
- {surf_cli-0.7.1 → surf_cli-0.8.0}/src/surf/orchestrator.py +194 -45
- {surf_cli-0.7.1 → surf_cli-0.8.0}/src/surf/test_adapters.py +14 -0
- {surf_cli-0.7.1 → surf_cli-0.8.0}/src/surf/test_logic.py +192 -0
- surf_cli-0.8.0/src/surf/test_orchestrator.py +1330 -0
- {surf_cli-0.7.1 → surf_cli-0.8.0}/src/surf/test_tex_logic.py +22 -2
- {surf_cli-0.7.1 → surf_cli-0.8.0}/uv.lock +51 -1
- surf_cli-0.7.1/src/surf/test_orchestrator.py +0 -692
- {surf_cli-0.7.1 → surf_cli-0.8.0}/.agents/skills/surf/references/surf.pdf +0 -0
- {surf_cli-0.7.1 → surf_cli-0.8.0}/.agents/skills/surf/references/surf.tex +0 -0
- {surf_cli-0.7.1 → surf_cli-0.8.0}/.github/workflows/publish.yml +0 -0
- {surf_cli-0.7.1 → surf_cli-0.8.0}/.gitignore +0 -0
- {surf_cli-0.7.1 → surf_cli-0.8.0}/.grok-plugin/marketplace.json +0 -0
- {surf_cli-0.7.1 → surf_cli-0.8.0}/LICENSE +0 -0
- {surf_cli-0.7.1 → surf_cli-0.8.0}/RELEASING.md +0 -0
- {surf_cli-0.7.1 → surf_cli-0.8.0}/flake.lock +0 -0
- {surf_cli-0.7.1 → surf_cli-0.8.0}/scripts/deploy.sh +0 -0
- {surf_cli-0.7.1 → surf_cli-0.8.0}/scripts/lint.sh +0 -0
- {surf_cli-0.7.1 → surf_cli-0.8.0}/scripts/test.sh +0 -0
- {surf_cli-0.7.1 → surf_cli-0.8.0}/src/surf/__main__.py +0 -0
- {surf_cli-0.7.1 → surf_cli-0.8.0}/src/surf/test_extract_section_perf_smoke.py +0 -0
- {surf_cli-0.7.1 → surf_cli-0.8.0}/src/surf/test_models.py +0 -0
|
@@ -8,7 +8,7 @@ metadata:
|
|
|
8
8
|
github_username: saintx
|
|
9
9
|
email: alex@saintx.us
|
|
10
10
|
twitter: alexsaintx
|
|
11
|
-
surf-version: "0.
|
|
11
|
+
surf-version: "0.8.0"
|
|
12
12
|
---
|
|
13
13
|
|
|
14
14
|
# Surf: Progressive Context Disclosure
|
|
@@ -40,6 +40,7 @@ Resolve with `surf` against the section heading; do not bulk-ingest a whole refe
|
|
|
40
40
|
| Wikilink and link input | `surf references/usage.md "Wikilink and link input"` |
|
|
41
41
|
| Batch scanning | `surf references/usage.md "Batch scanning"` |
|
|
42
42
|
| Platform aggregation (batch section extraction) | `surf references/usage.md "Platform aggregation (batch section extraction)"` |
|
|
43
|
+
| Filter by frontmatter | `surf references/usage.md "Filter by frontmatter"` |
|
|
43
44
|
| Additional options | `surf references/usage.md "Additional options"` |
|
|
44
45
|
|
|
45
46
|
### TeX and PDF
|
|
@@ -5,7 +5,7 @@ metadata:
|
|
|
5
5
|
github_username: saintx
|
|
6
6
|
email: alex@saintx.us
|
|
7
7
|
twitter: alexsaintx
|
|
8
|
-
surf-version: "0.
|
|
8
|
+
surf-version: "0.8.0"
|
|
9
9
|
---
|
|
10
10
|
# Surf Usage
|
|
11
11
|
|
|
@@ -101,25 +101,32 @@ The `.md` extension is implicit. `surf ~/path/to/file` resolves to `~/path/to/fi
|
|
|
101
101
|
Scan YAML across a directory without loading any body:
|
|
102
102
|
|
|
103
103
|
```bash
|
|
104
|
-
|
|
104
|
+
surf -f ~/path/to/*.md
|
|
105
105
|
```
|
|
106
106
|
|
|
107
|
-
|
|
107
|
+
Two or more files print under `==> path <==` headers. Nested trees:
|
|
108
108
|
|
|
109
109
|
```bash
|
|
110
|
-
|
|
110
|
+
surf -f ~/path/to/*/file.md
|
|
111
111
|
```
|
|
112
112
|
|
|
113
113
|
## Platform aggregation (batch section extraction)
|
|
114
114
|
|
|
115
|
-
|
|
115
|
+
Extract one heading from each matching file. Two or more files print under `==> path <==` headers:
|
|
116
116
|
|
|
117
117
|
```bash
|
|
118
|
-
|
|
119
|
-
| xargs -I{} sh -c 'echo "### {}" && surf {} "Some Heading" && echo'
|
|
118
|
+
surf -s "Some Heading" ~/path/to/*/file.md
|
|
120
119
|
```
|
|
121
120
|
|
|
122
|
-
Change the
|
|
121
|
+
Change the glob and the heading text to match the files you have.
|
|
122
|
+
|
|
123
|
+
## Filter by frontmatter
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
surf --where metadata.skill-family=skill-authoring -s Overview ~/path/to/*/references/about.md
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
`--where KEY=VALUE` keeps files whose YAML frontmatter matches. Dotted keys walk nested maps. A list value matches on membership. Repeat `--where` to require every clause. With no other mode flag, print matching paths.
|
|
123
130
|
|
|
124
131
|
## Additional options
|
|
125
132
|
|
|
@@ -129,8 +136,11 @@ Change the `find` root, the path glob, and the heading text to match the files y
|
|
|
129
136
|
| heading, no flag | Section content without frontmatter |
|
|
130
137
|
| `--list` | Heading tree only |
|
|
131
138
|
| `-f` / `--frontmatter-only` | YAML only |
|
|
139
|
+
| `-s` / `--section` | Heading to extract from each file. Every positional is a file. |
|
|
140
|
+
| `--where KEY=VALUE` | Keep files whose frontmatter matches. Repeatable and conjunctive. |
|
|
132
141
|
| `--full` | With a named heading: YAML frontmatter, then that section. Without a heading: the file map. |
|
|
133
142
|
| `--content-only` / `--body-only` | Same as a named heading with no flag. This is the default extract. |
|
|
134
143
|
| `--level N` | List: ranks 1 through N. Extract: exact rank N. |
|
|
135
144
|
| `--no-heading` | Omit the heading line from section output |
|
|
136
145
|
| `-o <file>` | Write output to a file. |
|
|
146
|
+
| `-v` / `--verbose` | Name each skipped file on stderr. |
|
|
@@ -3,14 +3,14 @@
|
|
|
3
3
|
"owner": { "name": "Alexander R. Saint Croix", "email": "alex@saintx.us" },
|
|
4
4
|
"metadata": {
|
|
5
5
|
"description": "Plugins shipped with the surf CLI.",
|
|
6
|
-
"version": "0.
|
|
6
|
+
"version": "0.8.0"
|
|
7
7
|
},
|
|
8
8
|
"plugins": [
|
|
9
9
|
{
|
|
10
10
|
"name": "surf",
|
|
11
11
|
"source": "./plugins/surf",
|
|
12
12
|
"description": "Extract a markdown heading, TeX section, or PDF outline item without loading the rest of the file.",
|
|
13
|
-
"version": "0.
|
|
13
|
+
"version": "0.8.0",
|
|
14
14
|
"category": "tooling",
|
|
15
15
|
"keywords": ["surf", "markdown", "tex", "pdf", "context", "skills"]
|
|
16
16
|
}
|
|
@@ -27,7 +27,7 @@ src/surf/
|
|
|
27
27
|
orchestrator.py CLI: argument parsing, dispatch, exit codes
|
|
28
28
|
test_*.py tests, beside the modules they cover
|
|
29
29
|
plugins/surf/ the agent skill and its manifests
|
|
30
|
-
scripts/ test.sh, lint.sh, deploy.sh
|
|
30
|
+
scripts/ test.sh, lint.sh, deploy.sh, spec-coverage.py
|
|
31
31
|
```
|
|
32
32
|
|
|
33
33
|
The layers only depend downward, and `pypdf` is confined to `adapters.py`. Four `import-linter` contracts in `pyproject.toml` enforce this, and `scripts/lint.sh` runs them.
|
|
@@ -35,10 +35,11 @@ The layers only depend downward, and `pypdf` is confined to `adapters.py`. Four
|
|
|
35
35
|
## Tests
|
|
36
36
|
|
|
37
37
|
```bash
|
|
38
|
-
scripts/test.sh
|
|
38
|
+
scripts/test.sh # uv run pytest src/surf -q
|
|
39
|
+
python scripts/spec-coverage.py # which spec scenarios have a passing test
|
|
39
40
|
```
|
|
40
41
|
|
|
41
|
-
|
|
42
|
+
Unit tests live in `src/surf/test_*.py` and are excluded from the wheel. A test that demonstrates a scenario cites it with a docstring line `spec: <slug>#Scenario`. `python scripts/spec-coverage.py` reports which scenarios under `spec/features/` those citations cover.
|
|
42
43
|
|
|
43
44
|
## Lint
|
|
44
45
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: surf-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.0
|
|
4
4
|
Summary: List headings, or extract one section by name, from Markdown, TeX, or PDF.
|
|
5
5
|
Project-URL: Homepage, https://github.com/saintx/surf-cli
|
|
6
6
|
Project-URL: Repository, https://github.com/saintx/surf-cli
|
|
@@ -71,7 +71,7 @@ metadata:
|
|
|
71
71
|
github_username: saintx
|
|
72
72
|
email: alex@saintx.us
|
|
73
73
|
twitter: alexsaintx
|
|
74
|
-
surf-version: "0.
|
|
74
|
+
surf-version: "0.8.0"
|
|
75
75
|
---
|
|
76
76
|
|
|
77
77
|
- Surf — About
|
|
@@ -237,7 +237,13 @@ Matching ignores case. `surf guide` resolves to `guide.md`; `.tex` and `.pdf` ne
|
|
|
237
237
|
**Scan a directory.** Frontmatter across many files, no body loaded:
|
|
238
238
|
|
|
239
239
|
```bash
|
|
240
|
-
|
|
240
|
+
surf -f docs/*.md
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
Keep files whose frontmatter matches a key:
|
|
244
|
+
|
|
245
|
+
```bash
|
|
246
|
+
surf --where title=Guide docs/*.md
|
|
241
247
|
```
|
|
242
248
|
|
|
243
249
|
`surf --help` lists every flag.
|
|
@@ -36,7 +36,7 @@ metadata:
|
|
|
36
36
|
github_username: saintx
|
|
37
37
|
email: alex@saintx.us
|
|
38
38
|
twitter: alexsaintx
|
|
39
|
-
surf-version: "0.
|
|
39
|
+
surf-version: "0.8.0"
|
|
40
40
|
---
|
|
41
41
|
|
|
42
42
|
- Surf — About
|
|
@@ -202,7 +202,13 @@ Matching ignores case. `surf guide` resolves to `guide.md`; `.tex` and `.pdf` ne
|
|
|
202
202
|
**Scan a directory.** Frontmatter across many files, no body loaded:
|
|
203
203
|
|
|
204
204
|
```bash
|
|
205
|
-
|
|
205
|
+
surf -f docs/*.md
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Keep files whose frontmatter matches a key:
|
|
209
|
+
|
|
210
|
+
```bash
|
|
211
|
+
surf --where title=Guide docs/*.md
|
|
206
212
|
```
|
|
207
213
|
|
|
208
214
|
`surf --help` lists every flag.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "surf",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Extract a markdown heading, TeX section, or PDF outline item without loading the rest of the file. Skill for agents using the surf CLI.",
|
|
5
5
|
"author": { "name": "Alexander R. Saint Croix", "email": "alex@saintx.us" },
|
|
6
6
|
"homepage": "https://github.com/saintx/surf-cli",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
|
3
3
|
"name": "surf",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.8.0",
|
|
5
5
|
"description": "Extract a markdown heading, TeX section, or PDF outline item without loading the rest of the file. Skill for agents using the surf CLI.",
|
|
6
6
|
"author": { "name": "Alexander R. Saint Croix", "email": "alex@saintx.us", "url": "https://github.com/saintx" },
|
|
7
7
|
"homepage": "https://github.com/saintx/surf-cli",
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "surf-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.8.0"
|
|
8
8
|
description = "List headings, or extract one section by name, from Markdown, TeX, or PDF."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { file = "LICENSE" }
|
|
@@ -37,6 +37,7 @@ dev = [
|
|
|
37
37
|
"isort>=6.0.1",
|
|
38
38
|
"pyright>=1.1.407",
|
|
39
39
|
"pytest>=9.1.1",
|
|
40
|
+
"pyyaml>=6",
|
|
40
41
|
]
|
|
41
42
|
|
|
42
43
|
[tool.uv]
|
|
@@ -85,3 +86,10 @@ source_modules = ["surf.models", "surf.logic", "surf.orchestrator"]
|
|
|
85
86
|
forbidden_modules = ["pypdf"]
|
|
86
87
|
type = "forbidden"
|
|
87
88
|
allow_indirect_imports = true
|
|
89
|
+
|
|
90
|
+
[[tool.importlinter.contracts]]
|
|
91
|
+
name = "Models, logic, and orchestrator cannot import yaml"
|
|
92
|
+
source_modules = ["surf.models", "surf.logic", "surf.orchestrator"]
|
|
93
|
+
forbidden_modules = ["yaml"]
|
|
94
|
+
type = "forbidden"
|
|
95
|
+
allow_indirect_imports = true
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# scripts/spec-coverage.py
|
|
3
|
+
"""Which scenarios under spec/features/ are demonstrated by passing tests.
|
|
4
|
+
|
|
5
|
+
python scripts/spec-coverage.py [--junit results.xml] [--tests 'src/surf/test_*.py'] [--strict]
|
|
6
|
+
|
|
7
|
+
Scenarios come from `surf spec/features/<slug>.md --list` for every feature file:
|
|
8
|
+
the H1 is the feature, each level-2 heading is a scenario, and a scenario's
|
|
9
|
+
address is "<slug>#<Scenario>".
|
|
10
|
+
|
|
11
|
+
A test cites the scenario it demonstrates with a docstring line:
|
|
12
|
+
|
|
13
|
+
def test_nested_path_selects_child():
|
|
14
|
+
\"\"\"spec: section-extraction#Nested path selects the child inside the named parent\"\"\"
|
|
15
|
+
|
|
16
|
+
One test may cite several scenarios, one per line. Outcomes come from pytest's
|
|
17
|
+
JUnit report; without --junit the script runs `uv run pytest src/surf --junitxml`
|
|
18
|
+
itself.
|
|
19
|
+
|
|
20
|
+
Forward check: every cited address exists in the spec (else "unknown address").
|
|
21
|
+
Reverse check: every scenario is cited by at least one passing test (else
|
|
22
|
+
"untested" or "failing"). Exit 1 on any failing or unknown; with --strict, also
|
|
23
|
+
on any untested.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import argparse
|
|
29
|
+
import ast
|
|
30
|
+
import glob
|
|
31
|
+
import re
|
|
32
|
+
import subprocess
|
|
33
|
+
import sys
|
|
34
|
+
import tempfile
|
|
35
|
+
import xml.etree.ElementTree as ET
|
|
36
|
+
from collections import defaultdict
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
|
|
39
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
40
|
+
FEATURES = ROOT / "spec" / "features"
|
|
41
|
+
_CITE_RE = re.compile(r"^\s*spec:\s*(.+?)\s*$")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def scenarios_from_spec() -> list[tuple[str, str, str, str]]:
|
|
45
|
+
"""(slug, feature name, status, scenario) in file order."""
|
|
46
|
+
rows: list[tuple[str, str, str, str]] = []
|
|
47
|
+
for path in sorted(FEATURES.glob("*.md")):
|
|
48
|
+
slug = path.stem
|
|
49
|
+
fm = subprocess.run(
|
|
50
|
+
["uv", "run", "surf", "-f", str(path)],
|
|
51
|
+
capture_output=True,
|
|
52
|
+
text=True,
|
|
53
|
+
cwd=ROOT,
|
|
54
|
+
).stdout
|
|
55
|
+
status = next(
|
|
56
|
+
(ln.split(":", 1)[1].strip() for ln in fm.splitlines() if ln.startswith("status:")),
|
|
57
|
+
"?",
|
|
58
|
+
)
|
|
59
|
+
out = subprocess.run(
|
|
60
|
+
["uv", "run", "surf", str(path), "--list"],
|
|
61
|
+
capture_output=True,
|
|
62
|
+
text=True,
|
|
63
|
+
check=True,
|
|
64
|
+
cwd=ROOT,
|
|
65
|
+
).stdout
|
|
66
|
+
feature = None
|
|
67
|
+
for line in out.splitlines():
|
|
68
|
+
indent = len(line) - len(line.lstrip(" "))
|
|
69
|
+
text = line.strip()[2:]
|
|
70
|
+
if indent == 0:
|
|
71
|
+
feature = text
|
|
72
|
+
elif indent == 2 and feature is not None:
|
|
73
|
+
rows.append((slug, feature, status, text))
|
|
74
|
+
return rows
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def citations_from_tests(pattern: str) -> dict[str, list[str]]:
|
|
78
|
+
cites: dict[str, list[str]] = {}
|
|
79
|
+
for path in sorted(glob.glob(pattern, recursive=True)):
|
|
80
|
+
tree = ast.parse(Path(path).read_text(encoding="utf-8"))
|
|
81
|
+
module = Path(path).stem
|
|
82
|
+
for node in ast.walk(tree):
|
|
83
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name.startswith(
|
|
84
|
+
"test"
|
|
85
|
+
):
|
|
86
|
+
doc = ast.get_docstring(node) or ""
|
|
87
|
+
addrs = [m.group(1) for ln in doc.splitlines() if (m := _CITE_RE.match(ln))]
|
|
88
|
+
if addrs:
|
|
89
|
+
cites[f"{module}::{node.name}"] = addrs
|
|
90
|
+
return cites
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def outcomes_from_junit(path: Path) -> dict[str, str]:
|
|
94
|
+
result: dict[str, str] = {}
|
|
95
|
+
for case in ET.parse(path).getroot().iter("testcase"):
|
|
96
|
+
module = case.get("classname", "").split(".")[-1]
|
|
97
|
+
name = case.get("name", "").split("[")[0]
|
|
98
|
+
status = "passed"
|
|
99
|
+
for child in case:
|
|
100
|
+
if child.tag in ("failure", "error"):
|
|
101
|
+
status = "failed"
|
|
102
|
+
elif child.tag == "skipped":
|
|
103
|
+
status = "skipped"
|
|
104
|
+
key = f"{module}::{name}"
|
|
105
|
+
if result.get(key) != "failed":
|
|
106
|
+
result[key] = status
|
|
107
|
+
return result
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def run_pytest_junit() -> Path:
|
|
111
|
+
tmp = Path(tempfile.mkdtemp()) / "results.xml"
|
|
112
|
+
proc = subprocess.run(
|
|
113
|
+
["uv", "run", "pytest", "src/surf", "-q", f"--junitxml={tmp}"],
|
|
114
|
+
cwd=ROOT,
|
|
115
|
+
capture_output=True,
|
|
116
|
+
text=True,
|
|
117
|
+
)
|
|
118
|
+
if not tmp.exists():
|
|
119
|
+
sys.stderr.write("spec-coverage.py: pytest produced no JUnit report\n")
|
|
120
|
+
sys.stderr.write(proc.stdout[-2000:] + proc.stderr[-2000:])
|
|
121
|
+
sys.exit(2)
|
|
122
|
+
return tmp
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def main(argv: list[str] | None = None) -> int:
|
|
126
|
+
parser = argparse.ArgumentParser(
|
|
127
|
+
prog="spec-coverage.py", description=__doc__.split("\n\n")[0]
|
|
128
|
+
)
|
|
129
|
+
parser.add_argument(
|
|
130
|
+
"--junit", type=Path, help="pytest --junitxml report; runs pytest if omitted"
|
|
131
|
+
)
|
|
132
|
+
parser.add_argument(
|
|
133
|
+
"--tests", default="src/surf/test_*.py", help="glob of test files to scan for citations"
|
|
134
|
+
)
|
|
135
|
+
parser.add_argument("--strict", action="store_true", help="also fail on untested scenarios")
|
|
136
|
+
args = parser.parse_args(argv)
|
|
137
|
+
|
|
138
|
+
rows = scenarios_from_spec()
|
|
139
|
+
addresses = {f"{slug}#{scenario}" for slug, _, _, scenario in rows}
|
|
140
|
+
pattern = args.tests if Path(args.tests).is_absolute() else str(ROOT / args.tests)
|
|
141
|
+
cites = citations_from_tests(pattern)
|
|
142
|
+
outcomes = outcomes_from_junit(args.junit or run_pytest_junit())
|
|
143
|
+
|
|
144
|
+
by_address: dict[str, list[tuple[str, str]]] = defaultdict(list)
|
|
145
|
+
unknown: list[tuple[str, str]] = []
|
|
146
|
+
for test_id, addrs in cites.items():
|
|
147
|
+
for addr in addrs:
|
|
148
|
+
if addr in addresses:
|
|
149
|
+
by_address[addr].append((test_id, outcomes.get(test_id, "not run")))
|
|
150
|
+
else:
|
|
151
|
+
unknown.append((test_id, addr))
|
|
152
|
+
|
|
153
|
+
counts = {"passing": 0, "failing": 0, "untested": 0}
|
|
154
|
+
per_status: dict[str, dict[str, int]] = defaultdict(
|
|
155
|
+
lambda: {"passing": 0, "failing": 0, "untested": 0}
|
|
156
|
+
)
|
|
157
|
+
current = None
|
|
158
|
+
for slug, feature, status, scenario in rows:
|
|
159
|
+
if slug != current:
|
|
160
|
+
current = slug
|
|
161
|
+
print(f"\n{feature} [{slug}, {status}]")
|
|
162
|
+
tests = by_address.get(f"{slug}#{scenario}", [])
|
|
163
|
+
if not tests:
|
|
164
|
+
state = "untested"
|
|
165
|
+
elif any(o == "passed" for _, o in tests) and not any(o == "failed" for _, o in tests):
|
|
166
|
+
state = "passing"
|
|
167
|
+
else:
|
|
168
|
+
state = "failing"
|
|
169
|
+
counts[state] += 1
|
|
170
|
+
per_status[status][state] += 1
|
|
171
|
+
mark = {"passing": "✓", "failing": "✗", "untested": "·"}[state]
|
|
172
|
+
detail = ", ".join(t for t, _ in tests)
|
|
173
|
+
print(f" {mark} {scenario}" + (f" [{detail}]" if detail else ""))
|
|
174
|
+
|
|
175
|
+
if unknown:
|
|
176
|
+
print("\nunknown addresses (cited by a test, absent from the spec):")
|
|
177
|
+
for test_id, addr in unknown:
|
|
178
|
+
print(f" {test_id} -> {addr}")
|
|
179
|
+
|
|
180
|
+
total = len(rows)
|
|
181
|
+
by_status = "; ".join(
|
|
182
|
+
f"{s}: {c['passing']} passing, {c['untested']} untested" for s, c in per_status.items()
|
|
183
|
+
)
|
|
184
|
+
print(
|
|
185
|
+
f"\n{total} scenarios: {counts['passing']} passing, {counts['failing']} failing, "
|
|
186
|
+
f"{counts['untested']} untested; {len(unknown)} unknown addresses ({by_status})"
|
|
187
|
+
)
|
|
188
|
+
bad = counts["failing"] or unknown or (args.strict and counts["untested"])
|
|
189
|
+
return 1 if bad else 0
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
if __name__ == "__main__":
|
|
193
|
+
sys.exit(main())
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# surf specification
|
|
2
|
+
|
|
3
|
+
One file per feature under `features/`. Each file carries frontmatter (`feature`, `status`, and the version it was verified against or targets), an H1 with the feature name, a `Feature:` fence, and one H2 per scenario with an intent line and a `Scenario:` fence. A feature's fences concatenate into a valid `.feature` file.
|
|
4
|
+
|
|
5
|
+
Every scenario in a file marked `status: shipped` was run against the version in that file's `verified-against` and prints what it says.
|
|
6
|
+
|
|
7
|
+
Addresses: `surf spec/features/<slug>.md --list` lists a feature's scenarios; `surf spec/features/<slug>.md "Scenario"` returns one. The status board is `surf -f spec/features/*.md`. Tests cite a scenario with a docstring line `spec: <slug>#Scenario`; `python scripts/spec-coverage.py` reports which scenarios are demonstrated by passing tests.
|
|
8
|
+
|
|
9
|
+
Fixtures: `references/` means `plugins/surf/skills/surf/references/` (`about.md`, `usage.md`, `surf.tex`, `surf.pdf`, one paper as TeX and as PDF). Other files a scenario names are given in full by the scenario's `Given` steps.
|
|
10
|
+
|
|
11
|
+
| Slug | Feature | Status |
|
|
12
|
+
|------|---------|--------|
|
|
13
|
+
| command-line | Command line | shipped |
|
|
14
|
+
| file-map | File map | shipped |
|
|
15
|
+
| frontmatter-only | Frontmatter only | shipped |
|
|
16
|
+
| heading-tree | Heading tree | shipped |
|
|
17
|
+
| section-extraction | Section extraction | shipped |
|
|
18
|
+
| link-targets | Link targets | shipped |
|
|
19
|
+
| what-counts-as-a-heading | What counts as a heading | shipped |
|
|
20
|
+
| tex-addressing | TeX addressing | shipped |
|
|
21
|
+
| pdf-addressing | PDF addressing | shipped |
|
|
22
|
+
| no-structural-index | No structural index | shipped |
|
|
23
|
+
| errors-and-exit-codes | Errors and exit codes | shipped |
|
|
24
|
+
| multiple-files | Multiple files | shipped |
|
|
25
|
+
| filter-by-frontmatter | Filter by frontmatter | shipped |
|
|
26
|
+
| frontmatter-grammar | Frontmatter grammar | shipped |
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
---
|
|
2
|
+
feature: Command line
|
|
3
|
+
status: shipped
|
|
4
|
+
verified-against: "0.7.1"
|
|
5
|
+
---
|
|
6
|
+
# Command line
|
|
7
|
+
|
|
8
|
+
The grammar of a call. The first positional is the target, a file path or a link; the second, when present, is the heading to extract.
|
|
9
|
+
|
|
10
|
+
```gherkin
|
|
11
|
+
Feature: Command line
|
|
12
|
+
The first positional is the target, a file path or a link.
|
|
13
|
+
The second, when present, is the heading to extract.
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Version flag prints the installed version
|
|
17
|
+
|
|
18
|
+
```gherkin
|
|
19
|
+
Scenario: Version flag prints the installed version
|
|
20
|
+
When I run "surf --version"
|
|
21
|
+
Then stdout is "surf " followed by the installed version number
|
|
22
|
+
And that version number has the form MAJOR.MINOR.PATCH
|
|
23
|
+
And the exit status is 0
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Positionals are target then heading
|
|
27
|
+
|
|
28
|
+
The order shows in the output: the first argument is opened, the second is looked up in it.
|
|
29
|
+
|
|
30
|
+
```gherkin
|
|
31
|
+
Scenario: Positionals are target then heading
|
|
32
|
+
Given a file "file.md" containing:
|
|
33
|
+
"""
|
|
34
|
+
# Doc
|
|
35
|
+
|
|
36
|
+
## My Heading
|
|
37
|
+
|
|
38
|
+
Body.
|
|
39
|
+
"""
|
|
40
|
+
When I run "surf file.md 'My Heading'"
|
|
41
|
+
Then stdout begins with "## My Heading"
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Reversed positionals name a missing file
|
|
45
|
+
|
|
46
|
+
```gherkin
|
|
47
|
+
Scenario: Reversed positionals name a missing file
|
|
48
|
+
Given a file "file.md" with a section "## My Heading"
|
|
49
|
+
And no file named "My Heading" exists
|
|
50
|
+
When I run "surf 'My Heading' file.md"
|
|
51
|
+
Then stderr is "Error: File not found: My Heading"
|
|
52
|
+
And the exit status is 2
|
|
53
|
+
```
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
---
|
|
2
|
+
feature: Errors and exit codes
|
|
3
|
+
status: shipped
|
|
4
|
+
verified-against: "0.7.1"
|
|
5
|
+
---
|
|
6
|
+
# Errors and exit codes
|
|
7
|
+
|
|
8
|
+
Exit 0 on success, including "no structural index"; 1 when the address is not found or the file cannot be read as its format; 2 on usage errors and missing files.
|
|
9
|
+
|
|
10
|
+
```gherkin
|
|
11
|
+
Feature: Errors and exit codes
|
|
12
|
+
Exit 0 on success, including "no structural index"; 1 when the address is not
|
|
13
|
+
found or the file cannot be read as its format; 2 on usage errors and missing files.
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## No target
|
|
17
|
+
|
|
18
|
+
```gherkin
|
|
19
|
+
Scenario: No target
|
|
20
|
+
When I run "surf" with no arguments
|
|
21
|
+
Then stderr is "Error: no target specified. Use surf --help for usage."
|
|
22
|
+
And the exit status is 2
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Heading not found
|
|
26
|
+
|
|
27
|
+
```gherkin
|
|
28
|
+
Scenario: Heading not found
|
|
29
|
+
Given the fixture file "guide.md", which has no heading "Nope"
|
|
30
|
+
When I run "surf guide.md Nope"
|
|
31
|
+
Then stderr is 'Error: heading "Nope" not found in guide.md.'
|
|
32
|
+
And the exit status is 1
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Missing file
|
|
36
|
+
|
|
37
|
+
```gherkin
|
|
38
|
+
Scenario: Missing file
|
|
39
|
+
Given no file named "nope.md" exists
|
|
40
|
+
When I run "surf nope.md"
|
|
41
|
+
Then stderr is "Error: File not found: nope.md"
|
|
42
|
+
And the exit status is 2
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Unreadable PDF
|
|
46
|
+
|
|
47
|
+
The PDF library prints its own warnings first; the error line is surf's.
|
|
48
|
+
|
|
49
|
+
```gherkin
|
|
50
|
+
Scenario: Unreadable PDF
|
|
51
|
+
Given a file "garbage.pdf" containing "not a pdf"
|
|
52
|
+
When I run "surf garbage.pdf --list"
|
|
53
|
+
Then stderr contains "Error: could not read PDF: garbage.pdf"
|
|
54
|
+
And stderr does not contain "Traceback"
|
|
55
|
+
And the exit status is 1
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Directory as target
|
|
59
|
+
|
|
60
|
+
```gherkin
|
|
61
|
+
Scenario: Directory as target
|
|
62
|
+
Given "." is a directory
|
|
63
|
+
When I run "surf ."
|
|
64
|
+
Then stderr is "Error: . is a directory"
|
|
65
|
+
And stderr does not contain "Traceback"
|
|
66
|
+
And the exit status is 2
|
|
67
|
+
```
|