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.
Files changed (52) hide show
  1. {surf_cli-0.7.1 → surf_cli-0.8.0}/.agents/skills/surf/SKILL.md +2 -1
  2. {surf_cli-0.7.1 → surf_cli-0.8.0}/.agents/skills/surf/references/about.md +1 -1
  3. {surf_cli-0.7.1 → surf_cli-0.8.0}/.agents/skills/surf/references/usage.md +18 -8
  4. {surf_cli-0.7.1 → surf_cli-0.8.0}/.claude-plugin/marketplace.json +2 -2
  5. {surf_cli-0.7.1 → surf_cli-0.8.0}/CONTRIBUTING.md +4 -3
  6. {surf_cli-0.7.1 → surf_cli-0.8.0}/PKG-INFO +9 -3
  7. {surf_cli-0.7.1 → surf_cli-0.8.0}/README.md +8 -2
  8. {surf_cli-0.7.1 → surf_cli-0.8.0}/flake.nix +1 -1
  9. {surf_cli-0.7.1 → surf_cli-0.8.0}/plugins/surf/.claude-plugin/plugin.json +1 -1
  10. {surf_cli-0.7.1 → surf_cli-0.8.0}/plugins/surf/plugin.json +1 -1
  11. {surf_cli-0.7.1 → surf_cli-0.8.0}/pyproject.toml +9 -1
  12. surf_cli-0.8.0/scripts/spec-coverage.py +193 -0
  13. surf_cli-0.8.0/spec/README.md +26 -0
  14. surf_cli-0.8.0/spec/features/command-line.md +53 -0
  15. surf_cli-0.8.0/spec/features/errors-and-exit-codes.md +67 -0
  16. surf_cli-0.8.0/spec/features/file-map.md +96 -0
  17. surf_cli-0.8.0/spec/features/filter-by-frontmatter.md +188 -0
  18. surf_cli-0.8.0/spec/features/frontmatter-grammar.md +55 -0
  19. surf_cli-0.8.0/spec/features/frontmatter-only.md +74 -0
  20. surf_cli-0.8.0/spec/features/heading-tree.md +96 -0
  21. surf_cli-0.8.0/spec/features/link-targets.md +61 -0
  22. surf_cli-0.8.0/spec/features/multiple-files.md +168 -0
  23. surf_cli-0.8.0/spec/features/no-structural-index.md +88 -0
  24. surf_cli-0.8.0/spec/features/pdf-addressing.md +89 -0
  25. surf_cli-0.8.0/spec/features/section-extraction.md +166 -0
  26. surf_cli-0.8.0/spec/features/tex-addressing.md +178 -0
  27. surf_cli-0.8.0/spec/features/what-counts-as-a-heading.md +127 -0
  28. {surf_cli-0.7.1 → surf_cli-0.8.0}/src/surf/__init__.py +1 -1
  29. {surf_cli-0.7.1 → surf_cli-0.8.0}/src/surf/adapters.py +7 -5
  30. {surf_cli-0.7.1 → surf_cli-0.8.0}/src/surf/logic.py +426 -0
  31. {surf_cli-0.7.1 → surf_cli-0.8.0}/src/surf/models.py +37 -1
  32. {surf_cli-0.7.1 → surf_cli-0.8.0}/src/surf/orchestrator.py +194 -45
  33. {surf_cli-0.7.1 → surf_cli-0.8.0}/src/surf/test_adapters.py +14 -0
  34. {surf_cli-0.7.1 → surf_cli-0.8.0}/src/surf/test_logic.py +192 -0
  35. surf_cli-0.8.0/src/surf/test_orchestrator.py +1330 -0
  36. {surf_cli-0.7.1 → surf_cli-0.8.0}/src/surf/test_tex_logic.py +22 -2
  37. {surf_cli-0.7.1 → surf_cli-0.8.0}/uv.lock +51 -1
  38. surf_cli-0.7.1/src/surf/test_orchestrator.py +0 -692
  39. {surf_cli-0.7.1 → surf_cli-0.8.0}/.agents/skills/surf/references/surf.pdf +0 -0
  40. {surf_cli-0.7.1 → surf_cli-0.8.0}/.agents/skills/surf/references/surf.tex +0 -0
  41. {surf_cli-0.7.1 → surf_cli-0.8.0}/.github/workflows/publish.yml +0 -0
  42. {surf_cli-0.7.1 → surf_cli-0.8.0}/.gitignore +0 -0
  43. {surf_cli-0.7.1 → surf_cli-0.8.0}/.grok-plugin/marketplace.json +0 -0
  44. {surf_cli-0.7.1 → surf_cli-0.8.0}/LICENSE +0 -0
  45. {surf_cli-0.7.1 → surf_cli-0.8.0}/RELEASING.md +0 -0
  46. {surf_cli-0.7.1 → surf_cli-0.8.0}/flake.lock +0 -0
  47. {surf_cli-0.7.1 → surf_cli-0.8.0}/scripts/deploy.sh +0 -0
  48. {surf_cli-0.7.1 → surf_cli-0.8.0}/scripts/lint.sh +0 -0
  49. {surf_cli-0.7.1 → surf_cli-0.8.0}/scripts/test.sh +0 -0
  50. {surf_cli-0.7.1 → surf_cli-0.8.0}/src/surf/__main__.py +0 -0
  51. {surf_cli-0.7.1 → surf_cli-0.8.0}/src/surf/test_extract_section_perf_smoke.py +0 -0
  52. {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.7.1"
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.7.1"
8
+ surf-version: "0.8.0"
9
9
  ---
10
10
  # Surf — About
11
11
 
@@ -5,7 +5,7 @@ metadata:
5
5
  github_username: saintx
6
6
  email: alex@saintx.us
7
7
  twitter: alexsaintx
8
- surf-version: "0.7.1"
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
- ls ~/path/to/*.md | xargs -I {} surf -f {}
104
+ surf -f ~/path/to/*.md
105
105
  ```
106
106
 
107
- Same pattern on nested trees:
107
+ Two or more files print under `==> path <==` headers. Nested trees:
108
108
 
109
109
  ```bash
110
- ls ~/path/to/*/file.md | xargs -I {} surf -f {}
110
+ surf -f ~/path/to/*/file.md
111
111
  ```
112
112
 
113
113
  ## Platform aggregation (batch section extraction)
114
114
 
115
- Print the path, then one heading, for each matching file:
115
+ Extract one heading from each matching file. Two or more files print under `==> path <==` headers:
116
116
 
117
117
  ```bash
118
- find ~/path/to -path '*/subdir/file.md' | sort \
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 `find` root, the path glob, and the heading text to match the files you have.
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.7.1"
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.7.1",
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 # uv run pytest src/surf -q
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
- Tests live in `src/surf/test_*.py` and are excluded from the wheel. The root `tests/` directory is unused.
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.7.1
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.7.1"
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
- ls docs/*.md | xargs -I {} surf -f {}
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.7.1"
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
- ls docs/*.md | xargs -I {} surf -f {}
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.
@@ -55,7 +55,7 @@
55
55
 
56
56
  surf = pythonPkgs.buildPythonApplication {
57
57
  pname = "surf";
58
- version = "0.7.1";
58
+ version = "0.8.0";
59
59
  src = cliSrc;
60
60
  pyproject = true;
61
61
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "surf",
3
- "version": "0.7.1",
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.7.1",
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.1"
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
+ ```