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.
Files changed (85) hide show
  1. backspec/__init__.py +16 -0
  2. backspec/host/__init__.py +0 -0
  3. backspec/host/adapters/__init__.py +0 -0
  4. backspec/host/adapters/cache.py +103 -0
  5. backspec/host/adapters/cache_admin.py +64 -0
  6. backspec/host/adapters/capabilities_file.py +39 -0
  7. backspec/host/adapters/claude_code.py +77 -0
  8. backspec/host/adapters/codex.py +67 -0
  9. backspec/host/adapters/fake.py +39 -0
  10. backspec/host/adapters/filesystem.py +50 -0
  11. backspec/host/adapters/review_yaml.py +33 -0
  12. backspec/host/adapters/treesitter.py +130 -0
  13. backspec/host/cli.py +313 -0
  14. backspec/host/composition.py +211 -0
  15. backspec/kernel/__init__.py +0 -0
  16. backspec/kernel/llm.py +64 -0
  17. backspec/kernel/prompts.py +10 -0
  18. backspec/kernel/templates.py +12 -0
  19. backspec/kernel/text.py +23 -0
  20. backspec/kernel/usage.py +50 -0
  21. backspec/modules/__init__.py +0 -0
  22. backspec/modules/capabilities/__init__.py +0 -0
  23. backspec/modules/capabilities/domain.py +108 -0
  24. backspec/modules/capabilities/facade.py +48 -0
  25. backspec/modules/capabilities/ports.py +21 -0
  26. backspec/modules/capabilities/use_cases.py +63 -0
  27. backspec/modules/discovery/__init__.py +0 -0
  28. backspec/modules/discovery/domain.py +87 -0
  29. backspec/modules/discovery/facade.py +16 -0
  30. backspec/modules/discovery/ports.py +14 -0
  31. backspec/modules/discovery/use_cases.py +19 -0
  32. backspec/modules/evidence/__init__.py +0 -0
  33. backspec/modules/evidence/domain.py +276 -0
  34. backspec/modules/evidence/facade.py +24 -0
  35. backspec/modules/evidence/ports.py +12 -0
  36. backspec/modules/evidence/use_cases.py +128 -0
  37. backspec/modules/extraction/__init__.py +0 -0
  38. backspec/modules/extraction/facade.py +6 -0
  39. backspec/modules/extraction/use_cases.py +228 -0
  40. backspec/modules/lint/__init__.py +0 -0
  41. backspec/modules/lint/domain.py +152 -0
  42. backspec/modules/lint/facade.py +7 -0
  43. backspec/modules/lint/use_cases.py +16 -0
  44. backspec/modules/report/__init__.py +0 -0
  45. backspec/modules/report/domain.py +128 -0
  46. backspec/modules/report/dto.py +92 -0
  47. backspec/modules/report/facade.py +11 -0
  48. backspec/modules/report/renderers.py +220 -0
  49. backspec/modules/report/use_cases.py +127 -0
  50. backspec/modules/requirements/__init__.py +0 -0
  51. backspec/modules/requirements/domain.py +272 -0
  52. backspec/modules/requirements/facade.py +26 -0
  53. backspec/modules/requirements/use_cases.py +135 -0
  54. backspec/modules/review/__init__.py +0 -0
  55. backspec/modules/review/domain.py +129 -0
  56. backspec/modules/review/facade.py +32 -0
  57. backspec/modules/review/ports.py +15 -0
  58. backspec/modules/review/use_cases.py +89 -0
  59. backspec/modules/staleness/__init__.py +0 -0
  60. backspec/modules/staleness/domain.py +75 -0
  61. backspec/modules/staleness/facade.py +17 -0
  62. backspec/modules/staleness/ports.py +7 -0
  63. backspec/modules/staleness/use_cases.py +39 -0
  64. backspec/modules/triage/__init__.py +0 -0
  65. backspec/modules/triage/domain.py +144 -0
  66. backspec/modules/triage/facade.py +24 -0
  67. backspec/modules/triage/use_cases.py +79 -0
  68. backspec/prompts/assess.md +32 -0
  69. backspec/prompts/assign.md +18 -0
  70. backspec/prompts/consolidate.md +52 -0
  71. backspec/prompts/observe.md +52 -0
  72. backspec/prompts/orient.md +33 -0
  73. backspec/prompts/synthesise.md +97 -0
  74. backspec/prompts/triage.md +34 -0
  75. backspec/py.typed +0 -0
  76. backspec/templates/libre-baskerville-400.woff2 +0 -0
  77. backspec/templates/libre-baskerville-700.woff2 +0 -0
  78. backspec/templates/libre-baskerville-OFL.txt +93 -0
  79. backspec/templates/report.html +336 -0
  80. backspec-0.0.1.dist-info/METADATA +430 -0
  81. backspec-0.0.1.dist-info/RECORD +85 -0
  82. backspec-0.0.1.dist-info/WHEEL +4 -0
  83. backspec-0.0.1.dist-info/entry_points.txt +2 -0
  84. backspec-0.0.1.dist-info/licenses/LICENSE +21 -0
  85. 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()
@@ -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()