backspec 0.0.1__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.
- backspec/__init__.py +16 -0
- backspec/host/__init__.py +0 -0
- backspec/host/adapters/__init__.py +0 -0
- backspec/host/adapters/cache.py +103 -0
- backspec/host/adapters/cache_admin.py +64 -0
- backspec/host/adapters/capabilities_file.py +39 -0
- backspec/host/adapters/claude_code.py +77 -0
- backspec/host/adapters/codex.py +67 -0
- backspec/host/adapters/fake.py +39 -0
- backspec/host/adapters/filesystem.py +50 -0
- backspec/host/adapters/review_yaml.py +33 -0
- backspec/host/adapters/treesitter.py +130 -0
- backspec/host/cli.py +313 -0
- backspec/host/composition.py +211 -0
- backspec/kernel/__init__.py +0 -0
- backspec/kernel/llm.py +64 -0
- backspec/kernel/prompts.py +10 -0
- backspec/kernel/templates.py +12 -0
- backspec/kernel/text.py +23 -0
- backspec/kernel/usage.py +50 -0
- backspec/modules/__init__.py +0 -0
- backspec/modules/capabilities/__init__.py +0 -0
- backspec/modules/capabilities/domain.py +108 -0
- backspec/modules/capabilities/facade.py +48 -0
- backspec/modules/capabilities/ports.py +21 -0
- backspec/modules/capabilities/use_cases.py +63 -0
- backspec/modules/discovery/__init__.py +0 -0
- backspec/modules/discovery/domain.py +87 -0
- backspec/modules/discovery/facade.py +16 -0
- backspec/modules/discovery/ports.py +14 -0
- backspec/modules/discovery/use_cases.py +19 -0
- backspec/modules/evidence/__init__.py +0 -0
- backspec/modules/evidence/domain.py +276 -0
- backspec/modules/evidence/facade.py +24 -0
- backspec/modules/evidence/ports.py +12 -0
- backspec/modules/evidence/use_cases.py +128 -0
- backspec/modules/extraction/__init__.py +0 -0
- backspec/modules/extraction/facade.py +6 -0
- backspec/modules/extraction/use_cases.py +228 -0
- backspec/modules/lint/__init__.py +0 -0
- backspec/modules/lint/domain.py +152 -0
- backspec/modules/lint/facade.py +7 -0
- backspec/modules/lint/use_cases.py +16 -0
- backspec/modules/report/__init__.py +0 -0
- backspec/modules/report/domain.py +128 -0
- backspec/modules/report/dto.py +92 -0
- backspec/modules/report/facade.py +11 -0
- backspec/modules/report/renderers.py +220 -0
- backspec/modules/report/use_cases.py +127 -0
- backspec/modules/requirements/__init__.py +0 -0
- backspec/modules/requirements/domain.py +272 -0
- backspec/modules/requirements/facade.py +26 -0
- backspec/modules/requirements/use_cases.py +135 -0
- backspec/modules/review/__init__.py +0 -0
- backspec/modules/review/domain.py +129 -0
- backspec/modules/review/facade.py +32 -0
- backspec/modules/review/ports.py +15 -0
- backspec/modules/review/use_cases.py +89 -0
- backspec/modules/staleness/__init__.py +0 -0
- backspec/modules/staleness/domain.py +75 -0
- backspec/modules/staleness/facade.py +17 -0
- backspec/modules/staleness/ports.py +7 -0
- backspec/modules/staleness/use_cases.py +39 -0
- backspec/modules/triage/__init__.py +0 -0
- backspec/modules/triage/domain.py +144 -0
- backspec/modules/triage/facade.py +24 -0
- backspec/modules/triage/use_cases.py +79 -0
- backspec/prompts/assess.md +32 -0
- backspec/prompts/assign.md +18 -0
- backspec/prompts/consolidate.md +52 -0
- backspec/prompts/observe.md +52 -0
- backspec/prompts/orient.md +33 -0
- backspec/prompts/synthesise.md +97 -0
- backspec/prompts/triage.md +34 -0
- backspec/py.typed +0 -0
- backspec/templates/libre-baskerville-400.woff2 +0 -0
- backspec/templates/libre-baskerville-700.woff2 +0 -0
- backspec/templates/libre-baskerville-OFL.txt +93 -0
- backspec/templates/report.html +336 -0
- backspec-0.0.1.dist-info/METADATA +430 -0
- backspec-0.0.1.dist-info/RECORD +85 -0
- backspec-0.0.1.dist-info/WHEEL +4 -0
- backspec-0.0.1.dist-info/entry_points.txt +2 -0
- backspec-0.0.1.dist-info/licenses/LICENSE +21 -0
- backspec-0.0.1.dist-info/licenses/src/backspec/templates/libre-baskerville-OFL.txt +93 -0
backspec/host/cli.py
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"""The command line. It holds no logic: it turns flags into a Config and prints what comes back.
|
|
2
|
+
Results go to stdout or the output file; progress goes to stderr."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Annotated, Optional
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from backspec.host.adapters.cache_admin import NotACache, describe
|
|
12
|
+
from backspec.host.composition import (CAPABILITIES_FILE, Config, cache_clear, cache_info, default_previous, extract,
|
|
13
|
+
load_result, orient, review_requirement, status, validate, write_tree)
|
|
14
|
+
from backspec.kernel.llm import LLMError
|
|
15
|
+
from backspec.modules.capabilities.facade import CapabilitiesFileError
|
|
16
|
+
from backspec.modules.discovery.facade import DiscoveryFacade
|
|
17
|
+
from backspec.modules.lint.facade import summary as lint_summary
|
|
18
|
+
from backspec.modules.report.facade import (ExtractionResult, RenderOutput, coverage_report, coverage_text, formats,
|
|
19
|
+
render)
|
|
20
|
+
from backspec.modules.staleness.facade import summary
|
|
21
|
+
from backspec.modules.review.facade import UnknownRequirement
|
|
22
|
+
|
|
23
|
+
app = typer.Typer(add_completion=False, no_args_is_help=True,
|
|
24
|
+
help="Extract user requirements from a codebase, with verified citations.")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
Include = Annotated[list[str], typer.Option("--include", help="Gitignore-style pattern to include. Repeatable.")]
|
|
28
|
+
Exclude = Annotated[list[str], typer.Option("--exclude", help="Gitignore-style pattern to exclude. Repeatable.")]
|
|
29
|
+
NoDefaultExcludes = Annotated[bool, typer.Option(help="Do not apply the built-in excludes.")]
|
|
30
|
+
ProviderOpt = Annotated[str, typer.Option("--provider", help="Model backend.")]
|
|
31
|
+
FastModel = Annotated[Optional[str], typer.Option(help="Model for observing, orienting and assigning.")]
|
|
32
|
+
Concurrency = Annotated[int, typer.Option(help="Parallel model calls.")]
|
|
33
|
+
CacheDir = Annotated[Optional[Path], typer.Option(help="Where to cache model replies.")]
|
|
34
|
+
NoCache = Annotated[bool, typer.Option(help="Do not read or write the cache.")]
|
|
35
|
+
Replay = Annotated[bool, typer.Option(help="Serve every call from the cache; fail on a miss.")]
|
|
36
|
+
CapabilitiesOpt = Annotated[Optional[Path], typer.Option(
|
|
37
|
+
"--capabilities", help=f"The capabilities file. Default: {CAPABILITIES_FILE} under PATH.")]
|
|
38
|
+
MULTI_FILE = ("openspec",) # formats that write a directory tree; `-o` is then required
|
|
39
|
+
ERRORS = (LLMError, OSError, ValueError) # CapabilitiesFileError and a bad result file are ValueErrors
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _say(message: str) -> None:
|
|
43
|
+
typer.echo(message, err=True)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _emit(text: str, output: Optional[Path]) -> None:
|
|
47
|
+
if output:
|
|
48
|
+
output.write_text(text, encoding="utf-8")
|
|
49
|
+
_say(f"written: {output}")
|
|
50
|
+
else:
|
|
51
|
+
typer.echo(text)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _check_format(fmt: str, output: Optional[Path]) -> None:
|
|
55
|
+
"""Refuse an unknown format, or a multi-file format without a directory, before any work."""
|
|
56
|
+
if fmt not in formats():
|
|
57
|
+
raise typer.BadParameter(f"unknown format {fmt!r}; known formats: {', '.join(formats())}", param_hint="--format")
|
|
58
|
+
if fmt in MULTI_FILE and output is None:
|
|
59
|
+
raise typer.BadParameter(f"--format {fmt} writes several files; -o DIRECTORY is required", param_hint="-o")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _emit_output(rendered: RenderOutput, output: Optional[Path]) -> None:
|
|
63
|
+
if rendered.is_tree:
|
|
64
|
+
paths = write_tree(rendered.files, output) # type: ignore[arg-type]
|
|
65
|
+
_say(f"written: {len(paths)} files under {output}")
|
|
66
|
+
else:
|
|
67
|
+
_emit(rendered.text or "", output)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@app.command("extract")
|
|
71
|
+
def extract_command(
|
|
72
|
+
path: Path = typer.Argument(..., help="The repository or directory to read."),
|
|
73
|
+
include: Include = [],
|
|
74
|
+
exclude: Exclude = [],
|
|
75
|
+
no_default_excludes: NoDefaultExcludes = False,
|
|
76
|
+
output: Optional[Path] = typer.Option(None, "-o", "--output", help="Write the result here instead of stdout."),
|
|
77
|
+
fmt: str = typer.Option("json", "--format", help="Output format: json, md, csv, html or openspec."),
|
|
78
|
+
provider: ProviderOpt = "claude-code",
|
|
79
|
+
model: Optional[str] = typer.Option(None, help="Model for synthesis and the support check."),
|
|
80
|
+
fast_model: FastModel = None,
|
|
81
|
+
no_assess_support: bool = typer.Option(False, help="Skip the support check."),
|
|
82
|
+
concurrency: Concurrency = 4,
|
|
83
|
+
cache_dir: CacheDir = None,
|
|
84
|
+
no_cache: NoCache = False,
|
|
85
|
+
replay: Replay = False,
|
|
86
|
+
capabilities: CapabilitiesOpt = None,
|
|
87
|
+
gate: bool = typer.Option(False, help="Write the capabilities file if there is none, then stop before "
|
|
88
|
+
"assignment and synthesis."),
|
|
89
|
+
dry_run: bool = typer.Option(False, help="List the files that would be read, and stop."),
|
|
90
|
+
review: Optional[Path] = typer.Option(None, "--review", help="The review file. Default: backspec.review.yaml in PATH, if present."),
|
|
91
|
+
no_reuse: bool = typer.Option(False, "--no-reuse", help="Read every file again, even those unchanged since "
|
|
92
|
+
"the previous result."),
|
|
93
|
+
previous: Optional[Path] = typer.Option(None, "--previous", help="An earlier JSON result whose ids to carry forward. "
|
|
94
|
+
"Default: the -o file, if it already exists."),
|
|
95
|
+
include_hidden: bool = typer.Option(False, "--include-hidden", help="csv: also list requirements rejected in review."),
|
|
96
|
+
strict: Optional[bool] = typer.Option(None, "--strict/--no-strict", help="openspec: leave out requirements with "
|
|
97
|
+
"no evidenced scenario (default: strict)."),
|
|
98
|
+
max_tokens: Optional[int] = typer.Option(None, "--max-tokens", min=1, help="Stop sending model calls once this "
|
|
99
|
+
"many tokens are used or reserved. The run is then partial."),
|
|
100
|
+
text_only: bool = typer.Option(False, "--text-only", help="Read every file as plain text, even if "
|
|
101
|
+
"tree-sitter is installed."),
|
|
102
|
+
no_triage: bool = typer.Option(False, "--no-triage", help="Read every file in full; skip the first look."),
|
|
103
|
+
triage_audit: float = typer.Option(0.1, "--triage-audit", metavar="FRACTION",
|
|
104
|
+
help="Share of the files triage skips that are read anyway, to check "
|
|
105
|
+
"triage. 0 turns the audit off, 1.0 reads everything triage skips."),
|
|
106
|
+
) -> None:
|
|
107
|
+
"""Run the pipeline on PATH."""
|
|
108
|
+
_check_format(fmt, output)
|
|
109
|
+
if not 0 <= triage_audit <= 1:
|
|
110
|
+
raise typer.BadParameter("--triage-audit must be between 0 and 1")
|
|
111
|
+
if dry_run:
|
|
112
|
+
from backspec.host.adapters.filesystem import DiskRepo
|
|
113
|
+
found = DiscoveryFacade(DiskRepo(path)).discover(include, exclude, not no_default_excludes)
|
|
114
|
+
lines = sum(len(f.lines) for f in found.files)
|
|
115
|
+
for source in found.files:
|
|
116
|
+
typer.echo(f"read {source.path}")
|
|
117
|
+
for left in found.left:
|
|
118
|
+
typer.echo(f"{left.state:9s} {left.path} ({left.reason})")
|
|
119
|
+
tokens = sum(len("\n".join(f.lines)) for f in found.files) // 4
|
|
120
|
+
_say(f"{len(found.files)} files, {lines} lines to read; {len(found.left)} left out. "
|
|
121
|
+
f"About {max(1, lines // 1300)} observe calls, about {tokens} tokens to read (estimated, "
|
|
122
|
+
f"characters / 4). Nothing was sent to a model.")
|
|
123
|
+
raise typer.Exit(0)
|
|
124
|
+
config = Config(include=include, exclude=exclude, default_excludes=not no_default_excludes,
|
|
125
|
+
assess_support=not no_assess_support, provider=provider, fast_model=fast_model,
|
|
126
|
+
strong_model=model, concurrency=concurrency, cache_dir=cache_dir,
|
|
127
|
+
use_cache=not no_cache, replay=replay, capabilities_file=capabilities, gate=gate,
|
|
128
|
+
review_file=review, previous=default_previous(previous, output, fmt), reuse=not no_reuse,
|
|
129
|
+
max_tokens=max_tokens, syntax=not text_only,
|
|
130
|
+
triage=not no_triage, triage_audit=triage_audit)
|
|
131
|
+
try:
|
|
132
|
+
result = extract(path, config, progress=_say)
|
|
133
|
+
except ERRORS as error:
|
|
134
|
+
_say(f"error: {error}")
|
|
135
|
+
raise typer.Exit(2)
|
|
136
|
+
_emit_output(render(result, fmt, include_hidden=include_hidden, strict=strict), output)
|
|
137
|
+
_say(f"{len(result.requirements)} requirements; {result.run.calls} model calls, "
|
|
138
|
+
f"{result.run.cached_calls} from cache; "
|
|
139
|
+
f"{result.run.input_tokens} tokens in, {result.run.output_tokens} out"
|
|
140
|
+
f"{' (estimated)' if result.run.tokens_estimated else ''}; status {result.run.status}")
|
|
141
|
+
if result.run.budget_reached:
|
|
142
|
+
_say(f"token budget reached: {result.run.input_tokens + result.run.output_tokens} tokens used "
|
|
143
|
+
f"(limit {max_tokens}); some calls were not sent and the result is partial")
|
|
144
|
+
if result.run.stopped_after:
|
|
145
|
+
_say(f"stopped after {result.run.stopped_after}, as asked by --gate")
|
|
146
|
+
raise typer.Exit(0)
|
|
147
|
+
raise typer.Exit(0 if result.run.status == "complete" else 1)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@app.command("orient")
|
|
151
|
+
def orient_command(
|
|
152
|
+
path: Path = typer.Argument(..., help="The repository or directory to read."),
|
|
153
|
+
include: Include = [],
|
|
154
|
+
exclude: Exclude = [],
|
|
155
|
+
no_default_excludes: NoDefaultExcludes = False,
|
|
156
|
+
provider: ProviderOpt = "claude-code",
|
|
157
|
+
fast_model: FastModel = None,
|
|
158
|
+
concurrency: Concurrency = 4,
|
|
159
|
+
cache_dir: CacheDir = None,
|
|
160
|
+
no_cache: NoCache = False,
|
|
161
|
+
replay: Replay = False,
|
|
162
|
+
capabilities: CapabilitiesOpt = None,
|
|
163
|
+
force: bool = typer.Option(False, "--force", help="Replace the capabilities file if it exists."),
|
|
164
|
+
text_only: bool = typer.Option(False, "--text-only", help="Read every file as plain text, even if "
|
|
165
|
+
"tree-sitter is installed."),
|
|
166
|
+
) -> None:
|
|
167
|
+
"""Propose the capabilities of PATH, write them to the capabilities file and stop."""
|
|
168
|
+
config = Config(include=include, exclude=exclude, default_excludes=not no_default_excludes, provider=provider,
|
|
169
|
+
fast_model=fast_model, concurrency=concurrency, cache_dir=cache_dir, use_cache=not no_cache,
|
|
170
|
+
replay=replay, capabilities_file=capabilities, syntax=not text_only)
|
|
171
|
+
try:
|
|
172
|
+
found = orient(path, config, progress=_say, force=force)
|
|
173
|
+
except (*ERRORS, FileExistsError, ValueError) as error:
|
|
174
|
+
_say(f"error: {error}")
|
|
175
|
+
raise typer.Exit(2)
|
|
176
|
+
for capability in found:
|
|
177
|
+
typer.echo(f"{capability.id}: {capability.name}")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@app.command("render")
|
|
181
|
+
def render_command(
|
|
182
|
+
file: Path = typer.Argument(..., help="A JSON result written by `backspec extract`."),
|
|
183
|
+
output: Optional[Path] = typer.Option(None, "-o", "--output", help="File to write; a directory for openspec."),
|
|
184
|
+
fmt: str = typer.Option("md", "--format", help="Output format: json, md, csv, html or openspec."),
|
|
185
|
+
technical: bool = typer.Option(False, help="Include technical-level requirements (md)."),
|
|
186
|
+
include_hidden: bool = typer.Option(False, "--include-hidden", help="csv: also list requirements rejected in review."),
|
|
187
|
+
strict: Optional[bool] = typer.Option(None, "--strict/--no-strict", help="openspec: leave out requirements with "
|
|
188
|
+
"no evidenced scenario (default: strict)."),
|
|
189
|
+
title: Optional[str] = typer.Option(None, "--title", help="html: the name shown on the page (default: the "
|
|
190
|
+
"name of the folder that was read)."),
|
|
191
|
+
) -> None:
|
|
192
|
+
"""Turn a saved JSON result into another format. No model is called."""
|
|
193
|
+
_check_format(fmt, output)
|
|
194
|
+
try:
|
|
195
|
+
result = load_result(file)
|
|
196
|
+
rendered = render(result, fmt, include_technical=technical, include_hidden=include_hidden, strict=strict,
|
|
197
|
+
title=title)
|
|
198
|
+
_emit_output(rendered, output)
|
|
199
|
+
except (OSError, ValueError) as error:
|
|
200
|
+
_say(f"error: {error}")
|
|
201
|
+
raise typer.Exit(2)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@app.command("schema")
|
|
205
|
+
def schema_command() -> None:
|
|
206
|
+
"""Print the JSON Schema of the result."""
|
|
207
|
+
typer.echo(json.dumps(ExtractionResult.model_json_schema(), indent=1))
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@app.command("status")
|
|
211
|
+
def status_command(
|
|
212
|
+
file: Path = typer.Argument(..., help="A JSON result written by `backspec extract`."),
|
|
213
|
+
root: Path = typer.Option(Path("."), "--root", help="The repository the result was made from."),
|
|
214
|
+
as_json: bool = typer.Option(False, "--json", help="Print the report as JSON."),
|
|
215
|
+
) -> None:
|
|
216
|
+
"""Show which requirements rest on files that have changed or gone. No model is called."""
|
|
217
|
+
try:
|
|
218
|
+
report = status(file, root)
|
|
219
|
+
except (OSError, ValueError) as error: # a pydantic ValidationError is a ValueError
|
|
220
|
+
_say(f"error: {file} is not a valid result: {error}")
|
|
221
|
+
raise typer.Exit(2)
|
|
222
|
+
typer.echo(report.model_dump_json(indent=1) if as_json else summary(report))
|
|
223
|
+
raise typer.Exit(0 if report.all_current else 1)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@app.command("review")
|
|
227
|
+
def review_command(
|
|
228
|
+
file: Path = typer.Argument(..., help="A JSON result written by `backspec extract`."),
|
|
229
|
+
requirement_id: str = typer.Argument(..., metavar="REQ-ID", help="The requirement to review."),
|
|
230
|
+
confirm: bool = typer.Option(False, "--confirm", help="The requirement is right."),
|
|
231
|
+
reject: bool = typer.Option(False, "--reject", help="The requirement is wrong; hide it."),
|
|
232
|
+
statement: Optional[str] = typer.Option(None, "--statement", help="Replace the statement."),
|
|
233
|
+
condition: list[str] = typer.Option([], "--condition", help="With --statement: a condition. Repeatable."),
|
|
234
|
+
reviewer: Optional[str] = typer.Option(None, "--reviewer", help="Who decided. Default: the current user."),
|
|
235
|
+
note: Optional[str] = typer.Option(None, "--note"),
|
|
236
|
+
review: Optional[Path] = typer.Option(None, "--review", help="The review file. Default: backspec.review.yaml here."),
|
|
237
|
+
) -> None:
|
|
238
|
+
"""Record a decision about one requirement, to be applied by later runs."""
|
|
239
|
+
try:
|
|
240
|
+
typer.echo(review_requirement(file, requirement_id, confirm=confirm, reject=reject, statement=statement,
|
|
241
|
+
conditions=condition, reviewer=reviewer, note=note, review_file=review))
|
|
242
|
+
except UnknownRequirement:
|
|
243
|
+
_say(f"error: {requirement_id} is not in {file}")
|
|
244
|
+
raise typer.Exit(2)
|
|
245
|
+
except (OSError, ValueError) as error:
|
|
246
|
+
_say(f"error: {error}")
|
|
247
|
+
raise typer.Exit(2)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@app.command("validate")
|
|
251
|
+
def validate_command(
|
|
252
|
+
file: Path = typer.Argument(..., help="A JSON result written by `backspec extract`."),
|
|
253
|
+
as_json: bool = typer.Option(False, "--json", help="Print the findings as JSON."),
|
|
254
|
+
strict: bool = typer.Option(False, "--strict", help="Count warnings as errors."),
|
|
255
|
+
) -> None:
|
|
256
|
+
"""Check a saved result against the schema, then lint it. No model is called."""
|
|
257
|
+
try:
|
|
258
|
+
report = validate(file)
|
|
259
|
+
except (OSError, ValueError) as error:
|
|
260
|
+
_say(f"error: {file} is not a valid result: {error}")
|
|
261
|
+
raise typer.Exit(2)
|
|
262
|
+
typer.echo(report.model_dump_json(indent=1) if as_json else lint_summary(report))
|
|
263
|
+
raise typer.Exit(1 if report.failed(strict) else 0)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
@app.command("coverage")
|
|
267
|
+
def coverage_command(
|
|
268
|
+
file: Path = typer.Argument(..., help="A JSON result written by `backspec extract`."),
|
|
269
|
+
as_json: bool = typer.Option(False, "--json", help="Print the report as JSON."),
|
|
270
|
+
show_all: bool = typer.Option(False, "--all", help="Do not truncate long lists."),
|
|
271
|
+
) -> None:
|
|
272
|
+
"""Show what happened to every file of a saved result. No model is called."""
|
|
273
|
+
try:
|
|
274
|
+
report = coverage_report(load_result(file))
|
|
275
|
+
except (OSError, ValueError) as error:
|
|
276
|
+
_say(f"error: {file} is not a valid result: {error}")
|
|
277
|
+
raise typer.Exit(2)
|
|
278
|
+
typer.echo(report.model_dump_json(indent=1) if as_json else coverage_text(report, show_all))
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
cache_app = typer.Typer(no_args_is_help=True, help="Look at or empty the cache of model replies.")
|
|
282
|
+
app.add_typer(cache_app, name="cache")
|
|
283
|
+
CachePath = Annotated[Path, typer.Argument(help="The repository whose cache to use.")]
|
|
284
|
+
CacheDirOpt = Annotated[Optional[Path], typer.Option("--cache-dir", help="The cache folder, instead of the default for PATH.")]
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
@cache_app.command("info")
|
|
288
|
+
def cache_info_command(path: CachePath = Path("."), cache_dir: CacheDirOpt = None) -> None:
|
|
289
|
+
"""Print the cache folder, the cached replies per stage and their size."""
|
|
290
|
+
try:
|
|
291
|
+
typer.echo(describe(cache_info(path, cache_dir)))
|
|
292
|
+
except NotACache as error:
|
|
293
|
+
_say(f"error: {error}")
|
|
294
|
+
raise typer.Exit(2)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
@cache_app.command("clear")
|
|
298
|
+
def cache_clear_command(
|
|
299
|
+
path: CachePath = Path("."), cache_dir: CacheDirOpt = None,
|
|
300
|
+
yes: bool = typer.Option(False, "--yes", help="Actually delete. Without it, only report."),
|
|
301
|
+
) -> None:
|
|
302
|
+
"""Delete the cached replies of a repository. Reports only, unless --yes."""
|
|
303
|
+
try:
|
|
304
|
+
info = cache_clear(path, cache_dir, delete=yes)
|
|
305
|
+
except NotACache as error:
|
|
306
|
+
_say(f"error: {error}")
|
|
307
|
+
raise typer.Exit(2)
|
|
308
|
+
if yes:
|
|
309
|
+
typer.echo(f"deleting {info.total} cached replies ({info.size} bytes) from {info.directory}")
|
|
310
|
+
typer.echo("deleted")
|
|
311
|
+
else:
|
|
312
|
+
typer.echo(f"would delete {info.total} cached replies ({info.size} bytes) from {info.directory}; "
|
|
313
|
+
"nothing deleted, pass --yes to delete")
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Where the adapters are chosen and handed to the modules. Nothing else in backspec knows which
|
|
2
|
+
model backend, file system or cache is in use."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime
|
|
6
|
+
import getpass
|
|
7
|
+
import hashlib
|
|
8
|
+
import os
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from backspec.host.adapters.cache import CachedLLM
|
|
13
|
+
from backspec.host.adapters import cache_admin
|
|
14
|
+
from backspec.host.adapters.capabilities_file import YamlCapabilitiesFile
|
|
15
|
+
from backspec.host.adapters.claude_code import ClaudeCode
|
|
16
|
+
from backspec.host.adapters.codex import Codex
|
|
17
|
+
from backspec.host.adapters.filesystem import DiskRepo
|
|
18
|
+
from backspec.host.adapters.treesitter import TreeSitterSyntax
|
|
19
|
+
from backspec.host.adapters.review_yaml import YamlReviewStore
|
|
20
|
+
from backspec.kernel.llm import LLMError, LLMPort
|
|
21
|
+
from backspec.modules.capabilities.facade import CapabilitiesFacade, Capability
|
|
22
|
+
from backspec.modules.discovery.facade import DiscoveryFacade
|
|
23
|
+
from backspec.modules.evidence.facade import EvidenceFacade
|
|
24
|
+
from backspec.modules.extraction.facade import Progress, Settings, orient_only, run
|
|
25
|
+
from backspec.modules.lint.facade import LintReport, lint
|
|
26
|
+
from backspec.modules.report.facade import ExtractionResult
|
|
27
|
+
from backspec.modules.requirements.facade import RequirementsFacade
|
|
28
|
+
from backspec.modules.review.facade import ReviewFacade
|
|
29
|
+
from backspec.modules.triage.facade import TriageFacade
|
|
30
|
+
from backspec.modules.staleness.facade import StalenessFacade, StatusReport
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
CAPABILITIES_FILE = "backspec.capabilities.yaml"
|
|
34
|
+
REVIEW_FILE = "backspec.review.yaml"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class Config:
|
|
39
|
+
"""Everything a run can be told. The command line and the library take the same fields."""
|
|
40
|
+
include: list[str] = field(default_factory=list)
|
|
41
|
+
exclude: list[str] = field(default_factory=list)
|
|
42
|
+
default_excludes: bool = True
|
|
43
|
+
assess_support: bool = True
|
|
44
|
+
provider: str | LLMPort = "claude-code"
|
|
45
|
+
fast_model: str | None = None
|
|
46
|
+
strong_model: str | None = None
|
|
47
|
+
concurrency: int = 4
|
|
48
|
+
cache_dir: Path | None = None
|
|
49
|
+
use_cache: bool = True
|
|
50
|
+
replay: bool = False
|
|
51
|
+
capabilities_file: Path | None = None # default: CAPABILITIES_FILE under the root
|
|
52
|
+
gate: bool = False # write the capabilities file if there is none, and stop before assignment
|
|
53
|
+
review_file: Path | None = None # default: backspec.review.yaml under the root, used only if it exists
|
|
54
|
+
previous: Path | None = None # a saved JSON result whose ids this run carries forward
|
|
55
|
+
max_tokens: int | None = None # stop sending calls once this many tokens are spent or reserved
|
|
56
|
+
syntax: bool = True # use symbols from tree-sitter when it is installed; False reads everything as text
|
|
57
|
+
reuse: bool = True # keep whatever the previous result settled and whose inputs have not changed
|
|
58
|
+
triage: bool = True # look at each file first; skip those a model is sure hold no behaviour
|
|
59
|
+
triage_audit: float = 0.1 # share of the skipped files read anyway: 0 is off, 1.0 reads them all
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def default_cache_dir(root: Path) -> Path:
|
|
63
|
+
"""A folder per repository under the user's cache directory, never inside the repository."""
|
|
64
|
+
base = Path(os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache")
|
|
65
|
+
return base / "backspec" / hashlib.sha256(str(root).encode()).hexdigest()[:16]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _provider(config: Config) -> tuple[LLMPort, str]:
|
|
69
|
+
if not isinstance(config.provider, str):
|
|
70
|
+
return config.provider, getattr(config.provider, "name", type(config.provider).__name__)
|
|
71
|
+
if config.provider == "claude-code":
|
|
72
|
+
return ClaudeCode(config.fast_model, config.strong_model), "claude-code"
|
|
73
|
+
if config.provider == "codex":
|
|
74
|
+
return Codex(config.fast_model, config.strong_model), "codex"
|
|
75
|
+
raise LLMError(f"Unknown provider {config.provider!r}. This version ships `claude-code` and `codex`; "
|
|
76
|
+
"pass a provider object to use another.")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _evidence(llm: LLMPort, config: Config) -> EvidenceFacade:
|
|
80
|
+
return EvidenceFacade(llm, config.concurrency, TreeSitterSyntax.load() if config.syntax else None)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _wiring(path: str | Path, config: Config):
|
|
84
|
+
root = Path(path).resolve()
|
|
85
|
+
if not root.is_dir():
|
|
86
|
+
raise NotADirectoryError(f"{root} is not a directory")
|
|
87
|
+
inner, name = _provider(config)
|
|
88
|
+
directory = (config.cache_dir or default_cache_dir(root)) if config.use_cache else None
|
|
89
|
+
llm = CachedLLM(inner, directory, config.replay, config.max_tokens)
|
|
90
|
+
settings = Settings(include=config.include, exclude=config.exclude, default_excludes=config.default_excludes,
|
|
91
|
+
assess_support=config.assess_support, gate=config.gate,
|
|
92
|
+
triage=config.triage, triage_audit=config.triage_audit)
|
|
93
|
+
store = YamlCapabilitiesFile(config.capabilities_file or root / CAPABILITIES_FILE)
|
|
94
|
+
return root, name, llm, settings, store
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def extract(path: str | Path, config: Config | None = None, progress: Progress = lambda _: None) -> ExtractionResult:
|
|
98
|
+
"""Read the code under `path` and return its requirements, observations and coverage.
|
|
99
|
+
|
|
100
|
+
A capabilities file, if there is one, is used as it is. With `Config(gate=True)` the run stops
|
|
101
|
+
once the capabilities are settled: requirements are empty, `run.status` is `partial` and
|
|
102
|
+
`run.stopped_after` is `capabilities`."""
|
|
103
|
+
config = config or Config()
|
|
104
|
+
root, name, llm, settings, store = _wiring(path, config)
|
|
105
|
+
review_path = config.review_file or root / REVIEW_FILE
|
|
106
|
+
if config.review_file and not review_path.is_file():
|
|
107
|
+
raise FileNotFoundError(f"review file not found: {review_path}")
|
|
108
|
+
review_store = YamlReviewStore(review_path) if review_path.is_file() else None
|
|
109
|
+
before = load_result(config.previous) if config.previous else None
|
|
110
|
+
if config.previous and root in Path(config.previous).resolve().parents:
|
|
111
|
+
# a result saved inside the repository is backspec's own output, never source to read
|
|
112
|
+
settings.exclude = [*settings.exclude, "/" + Path(config.previous).resolve().relative_to(root).as_posix()]
|
|
113
|
+
return run(root=root.name, provider=name, settings=settings,
|
|
114
|
+
discovery=DiscoveryFacade(DiskRepo(root)),
|
|
115
|
+
evidence=_evidence(llm, config),
|
|
116
|
+
capabilities=CapabilitiesFacade(llm, config.concurrency, store),
|
|
117
|
+
requirements=RequirementsFacade(llm, config.concurrency),
|
|
118
|
+
usage=llm.usage, spend=llm.spend, progress=progress, review=ReviewFacade(review_store), before=before,
|
|
119
|
+
reuse=config.reuse, triage=TriageFacade(llm, config.concurrency))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def status(result: ExtractionResult | str | Path, root: str | Path = ".") -> StatusReport:
|
|
123
|
+
"""Which requirements in a result rest on files that are no longer what was read.
|
|
124
|
+
`result` is an ExtractionResult or the path of a saved JSON result. No model is called."""
|
|
125
|
+
if not isinstance(result, ExtractionResult):
|
|
126
|
+
result = ExtractionResult.model_validate_json(Path(result).read_text(encoding="utf-8"))
|
|
127
|
+
return StalenessFacade(DiskRepo(root)).check(result)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def validate(result: ExtractionResult | str | Path) -> LintReport:
|
|
131
|
+
"""Deterministic checks on a result (or the path of a saved one). No model is called.
|
|
132
|
+
Raises ValueError for a file that is not a valid result."""
|
|
133
|
+
if not isinstance(result, ExtractionResult):
|
|
134
|
+
result = load_result(Path(result))
|
|
135
|
+
return lint(result)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def cache_directory(path: str | Path = ".", cache_dir: Path | None = None) -> Path:
|
|
139
|
+
"""The cache folder for the repository at `path`, as `extract` would use it."""
|
|
140
|
+
return cache_dir or default_cache_dir(Path(path).resolve())
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def cache_info(path: str | Path = ".", cache_dir: Path | None = None) -> cache_admin.CacheInfo:
|
|
144
|
+
return cache_admin.inspect(cache_directory(path, cache_dir))
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def cache_clear(path: str | Path = ".", cache_dir: Path | None = None, delete: bool = False) -> cache_admin.CacheInfo:
|
|
148
|
+
"""What the cache holds, deleted when `delete`. Raises cache_admin.NotACache for a folder that is not one."""
|
|
149
|
+
return cache_admin.clear(cache_directory(path, cache_dir), delete)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def orient(path: str | Path, config: Config | None = None, progress: Progress = lambda _: None,
|
|
153
|
+
force: bool = False) -> list[Capability]:
|
|
154
|
+
"""Propose the capabilities of the code under `path` and write them to the capabilities file.
|
|
155
|
+
Raises FileExistsError if the file exists, unless `force`."""
|
|
156
|
+
config = config or Config()
|
|
157
|
+
root, _, llm, settings, store = _wiring(path, config)
|
|
158
|
+
return orient_only(settings, DiscoveryFacade(DiskRepo(root)), _evidence(llm, config),
|
|
159
|
+
CapabilitiesFacade(llm, config.concurrency, store), force, progress)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def load_result(file: Path) -> ExtractionResult:
|
|
163
|
+
"""A saved JSON result."""
|
|
164
|
+
return ExtractionResult.model_validate_json(Path(file).read_text(encoding="utf-8"))
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def default_previous(previous: Path | None, output: Path | None, fmt: str = "json") -> Path | None:
|
|
168
|
+
"""The previous result: the one named, else the JSON output file if it already exists."""
|
|
169
|
+
if previous is not None:
|
|
170
|
+
return previous
|
|
171
|
+
return output if output is not None and fmt == "json" and output.is_file() else None
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def review_requirement(file: Path, requirement_id: str, *, confirm: bool = False, reject: bool = False,
|
|
175
|
+
statement: str | None = None, conditions: list[str] | None = None,
|
|
176
|
+
reviewer: str | None = None, note: str | None = None,
|
|
177
|
+
review_file: Path | None = None) -> str:
|
|
178
|
+
"""Record a decision about one requirement of a saved result, and say what was written.
|
|
179
|
+
Raises UnknownRequirement for an id the result does not have, ValueError for a bad request."""
|
|
180
|
+
if confirm + reject + (statement is not None) != 1:
|
|
181
|
+
raise ValueError("give exactly one of --confirm, --reject or --statement")
|
|
182
|
+
if conditions and statement is None:
|
|
183
|
+
raise ValueError("--condition goes with --statement")
|
|
184
|
+
decision = "confirm" if confirm else "reject" if reject else "edit"
|
|
185
|
+
path = review_file or Path(REVIEW_FILE)
|
|
186
|
+
if not reviewer:
|
|
187
|
+
try:
|
|
188
|
+
reviewer = getpass.getuser()
|
|
189
|
+
except Exception:
|
|
190
|
+
reviewer = "unknown"
|
|
191
|
+
ReviewFacade(YamlReviewStore(path)).record(
|
|
192
|
+
load_result(file).requirements, requirement_id, decision, # type: ignore[arg-type]
|
|
193
|
+
statement=statement, conditions=(conditions or []) if statement is not None else None,
|
|
194
|
+
reviewer=reviewer, date=datetime.date.today().isoformat(), note=note)
|
|
195
|
+
return f"{requirement_id}: {decision} by {reviewer} written to {path}"
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def write_tree(files: dict[str, str], directory: Path) -> list[Path]:
|
|
199
|
+
"""Write a rendered set of files (relative path -> text) under `directory`. A path that
|
|
200
|
+
would leave the directory is refused before anything is written."""
|
|
201
|
+
root = Path(directory).resolve()
|
|
202
|
+
targets = {}
|
|
203
|
+
for name, text in files.items():
|
|
204
|
+
target = (root / name).resolve()
|
|
205
|
+
if root != target and root not in target.parents:
|
|
206
|
+
raise ValueError(f"refusing to write outside {directory}: {name}")
|
|
207
|
+
targets[target] = text
|
|
208
|
+
for target, text in targets.items():
|
|
209
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
210
|
+
target.write_text(text, encoding="utf-8")
|
|
211
|
+
return list(targets)
|
|
File without changes
|
backspec/kernel/llm.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""The one port every stage uses to reach a model, and the rule for reading its replies.
|
|
2
|
+
|
|
3
|
+
A stage builds a prompt, says which tier it needs and what shape the answer must have. The
|
|
4
|
+
pipeline, not the adapter, parses and validates the reply, and asks again when it is malformed.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Literal, Protocol, TypeVar
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, ValidationError
|
|
14
|
+
|
|
15
|
+
Tier = Literal["fast", "strong"]
|
|
16
|
+
Reply = TypeVar("Reply", bound=BaseModel)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class LLMRequest:
|
|
21
|
+
stage: str
|
|
22
|
+
prompt: str
|
|
23
|
+
tier: Tier = "fast"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class LLMPort(Protocol):
|
|
27
|
+
def complete(self, request: LLMRequest) -> str:
|
|
28
|
+
"""The model's reply as text. Raises LLMError when no reply could be had."""
|
|
29
|
+
...
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class LLMError(Exception):
|
|
33
|
+
"""The model could not be reached, or never gave a reply in the required shape."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def extract_json(text: str) -> object:
|
|
37
|
+
"""The JSON value in a reply, whether bare, fenced, or surrounded by a sentence or two."""
|
|
38
|
+
text = text.strip()
|
|
39
|
+
fenced = re.search(r"```(?:json)?\s*\n(.*?)\n```", text, re.DOTALL)
|
|
40
|
+
if fenced:
|
|
41
|
+
text = fenced.group(1).strip()
|
|
42
|
+
try:
|
|
43
|
+
return json.loads(text)
|
|
44
|
+
except json.JSONDecodeError:
|
|
45
|
+
start, end = text.find("{"), text.rfind("}")
|
|
46
|
+
if start == -1 or end <= start:
|
|
47
|
+
raise
|
|
48
|
+
return json.loads(text[start:end + 1])
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def ask(llm: LLMPort, request: LLMRequest, shape: type[Reply], retries: int = 2) -> Reply:
|
|
52
|
+
"""Ask until the reply parses as `shape`, at most `retries` more times, telling the model
|
|
53
|
+
what was wrong with its last attempt."""
|
|
54
|
+
prompt = request.prompt
|
|
55
|
+
problem = ""
|
|
56
|
+
for _ in range(retries + 1):
|
|
57
|
+
text = llm.complete(LLMRequest(request.stage, prompt, request.tier))
|
|
58
|
+
try:
|
|
59
|
+
return shape.model_validate(extract_json(text))
|
|
60
|
+
except (json.JSONDecodeError, ValidationError) as error:
|
|
61
|
+
problem = str(error)[:1500]
|
|
62
|
+
prompt = (f"{request.prompt}\n\nYour previous reply could not be used:\n{problem}\n"
|
|
63
|
+
"Reply again with the JSON only, in exactly the shape asked for.")
|
|
64
|
+
raise LLMError(f"{request.stage}: no usable reply after {retries + 1} attempts: {problem}")
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""The prompt templates, shipped with the package. PROMPT_VERSION changes whenever one does."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from importlib import resources
|
|
5
|
+
|
|
6
|
+
PROMPT_VERSION = "2026-11-d"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def load(name: str) -> str:
|
|
10
|
+
return resources.files("backspec.prompts").joinpath(f"{name}.md").read_text(encoding="utf-8")
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""The page templates, shipped with the package."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from importlib import resources
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def load(name: str) -> str:
|
|
8
|
+
return resources.files("backspec.templates").joinpath(name).read_text(encoding="utf-8")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def load_bytes(name: str) -> bytes:
|
|
12
|
+
return resources.files("backspec.templates").joinpath(name).read_bytes()
|
backspec/kernel/text.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Small text helpers shared by every module."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def squash(text: str) -> str:
|
|
9
|
+
"""Text with every run of whitespace as one space, for comparing quotes with code."""
|
|
10
|
+
return re.sub(r"\s+", " ", text).strip()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def numbered(lines: list[str], first: int) -> list[str]:
|
|
14
|
+
"""Lines prefixed with their real line number. A model copies the number; it never counts."""
|
|
15
|
+
return [f"{first + offset:5d}| {line}" for offset, line in enumerate(lines)]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def short_hash(*parts: object) -> str:
|
|
19
|
+
return hashlib.sha256("|".join(str(part) for part in parts).encode()).hexdigest()[:8]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def sha256(text: str) -> str:
|
|
23
|
+
return "sha256:" + hashlib.sha256(text.encode()).hexdigest()
|