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
@@ -0,0 +1,50 @@
1
+ """What a model call cost, and the rules for estimating it before and after.
2
+
3
+ A backend may report the tokens of its last call (`last_usage()`); when it does not, the
4
+ cost is estimated from characters. Estimates are always flagged as such.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass, field
9
+
10
+ CHARS_PER_TOKEN = 4
11
+
12
+ # Tokens reserved for a stage's reply before a call is sent, on top of the prompt's own estimate.
13
+ # These are allowances, not limits: a reply may be longer. They bound how far a run can overshoot.
14
+ OUTPUT_ALLOWANCE = {"observe": 3000, "orient": 1500, "assign": 3000, "synthesise": 3000,
15
+ "consolidate": 3000, "assess": 2000}
16
+ DEFAULT_OUTPUT_ALLOWANCE = 2000
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class Usage:
21
+ input_tokens: int
22
+ output_tokens: int
23
+ estimated: bool = False
24
+ cost_usd: float | None = None # the backend's own figure for the call, when it gives one
25
+
26
+
27
+ def estimate_tokens(text: str) -> int:
28
+ """Characters divided by four, rounded up."""
29
+ return (len(text) + CHARS_PER_TOKEN - 1) // CHARS_PER_TOKEN
30
+
31
+
32
+ def estimate_usage(prompt: str, reply: str) -> Usage:
33
+ return Usage(estimate_tokens(prompt), estimate_tokens(reply), estimated=True)
34
+
35
+
36
+ def reservation(stage: str, prompt: str) -> int:
37
+ """What a call is expected to cost, held against the budget until it returns."""
38
+ return estimate_tokens(prompt) + OUTPUT_ALLOWANCE.get(stage, DEFAULT_OUTPUT_ALLOWANCE)
39
+
40
+
41
+ @dataclass
42
+ class Spend:
43
+ """A snapshot of what a run has used so far. Cached replies count as calls to their stage's
44
+ `cached`, never as tokens."""
45
+ input_tokens: int = 0
46
+ output_tokens: int = 0
47
+ estimated: bool = False
48
+ cost_usd: float | None = None
49
+ budget_reached: bool = False
50
+ by_stage: dict[str, dict[str, int]] = field(default_factory=dict) # stage -> calls, cached, input_tokens, output_tokens
File without changes
File without changes
@@ -0,0 +1,108 @@
1
+ """Capabilities: feature areas as a user would name them, and which observations belong to each.
2
+
3
+ Grouping is by observation, not by file: one large file often feeds several capabilities.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import re
8
+
9
+ from pydantic import BaseModel, Field
10
+
11
+ PLUMBING = "plumbing"
12
+ UNASSIGNED = "unassigned"
13
+ ID_PATTERN = re.compile(r"[a-z0-9_]+")
14
+
15
+
16
+ class CapabilitiesFileError(ValueError):
17
+ """The capabilities file is unusable. The message names the file and every problem."""
18
+
19
+
20
+ class Capability(BaseModel):
21
+ id: str
22
+ name: str
23
+ summary: str = ""
24
+ includes: list[str] = Field(default_factory=list)
25
+
26
+
27
+ class OrientReply(BaseModel):
28
+ capabilities: list[Capability]
29
+
30
+
31
+ class AssignReply(BaseModel):
32
+ assignments: dict[str, str]
33
+
34
+
35
+ def listing(observations: list[tuple[str, str, str]]) -> str:
36
+ """Observations as `id | statement` lines under a `## path` header per file.
37
+ Each item is (id, path, statement)."""
38
+ out: list[str] = []
39
+ current = None
40
+ for obs_id, path, statement in observations:
41
+ if path != current:
42
+ out.append(f"## {path}")
43
+ current = path
44
+ out.append(f"{obs_id} | {statement}")
45
+ return "\n".join(out)
46
+
47
+
48
+ def thin(items: list, limit: int) -> list:
49
+ """At most `limit` items, taken evenly across the list, so that every file is represented."""
50
+ if len(items) <= limit:
51
+ return items
52
+ step = len(items) / limit
53
+ return [items[int(index * step)] for index in range(limit)]
54
+
55
+
56
+ def _text(value: object) -> bool:
57
+ return isinstance(value, str) and bool(value.strip())
58
+
59
+
60
+ def parse_capabilities(data: object, source: str) -> list[Capability]:
61
+ """Turn what was read from a capabilities file into capabilities, or refuse it.
62
+
63
+ Every problem is collected, so the owner can fix the file in one pass. `plumbing` is added
64
+ when the file lacks it."""
65
+ problems: list[str] = []
66
+ if isinstance(data, dict) and isinstance(data.get("capabilities"), list):
67
+ data = data["capabilities"]
68
+ if not isinstance(data, list):
69
+ raise CapabilitiesFileError(f"{source}: expected a list of capabilities (or a `capabilities:` key holding one)")
70
+ found: list[Capability] = []
71
+ seen: set[str] = set()
72
+ for position, entry in enumerate(data, start=1):
73
+ label = f"entry {position}"
74
+ if not isinstance(entry, dict):
75
+ problems.append(f"{label}: expected a mapping with id, name, summary and includes")
76
+ continue
77
+ cap_id = entry.get("id")
78
+ if isinstance(cap_id, str) and cap_id:
79
+ label = f"entry {position} ({cap_id})"
80
+ if not isinstance(cap_id, str) or not ID_PATTERN.fullmatch(cap_id):
81
+ problems.append(f"{label}: id must be lowercase letters, digits and underscores, got {cap_id!r}")
82
+ elif cap_id in seen:
83
+ problems.append(f"{label}: id {cap_id!r} is used more than once")
84
+ else:
85
+ seen.add(cap_id)
86
+ if not _text(entry.get("name")):
87
+ problems.append(f"{label}: name must not be empty")
88
+ summary, includes = entry.get("summary", ""), entry.get("includes", [])
89
+ if summary is None:
90
+ summary = ""
91
+ if not isinstance(summary, str):
92
+ problems.append(f"{label}: summary must be text")
93
+ if includes is None:
94
+ includes = []
95
+ if not isinstance(includes, list) or not all(isinstance(item, str) for item in includes):
96
+ problems.append(f"{label}: includes must be a list of text")
97
+ if not problems:
98
+ found.append(Capability(id=cap_id, name=entry["name"].strip(), summary=summary.strip(), includes=includes))
99
+ if problems:
100
+ raise CapabilitiesFileError(f"{source} is not usable:\n" + "\n".join(f" - {p}" for p in problems))
101
+ return with_plumbing(found)
102
+
103
+
104
+ def with_plumbing(capabilities: list[Capability]) -> list[Capability]:
105
+ if any(c.id == PLUMBING for c in capabilities):
106
+ return list(capabilities)
107
+ return [*capabilities, Capability(id=PLUMBING, name="Internal mechanics",
108
+ summary="Machinery no user or operator would notice.")]
@@ -0,0 +1,48 @@
1
+ """Capabilities, as the rest of backspec sees it."""
2
+ from __future__ import annotations
3
+
4
+ from backspec.kernel.llm import LLMPort
5
+ from backspec.modules.capabilities.domain import PLUMBING, UNASSIGNED, CapabilitiesFileError, Capability
6
+ from backspec.modules.capabilities.ports import CapabilitiesStorePort
7
+ from backspec.modules.capabilities.use_cases import assign, load, orient
8
+ from backspec.modules.evidence.facade import Observation
9
+
10
+ __all__ = ["PLUMBING", "UNASSIGNED", "CapabilitiesFacade", "CapabilitiesFileError", "CapabilitiesStorePort",
11
+ "Capability"]
12
+
13
+
14
+ class CapabilitiesFacade:
15
+ def __init__(self, llm: LLMPort, workers: int = 4, store: CapabilitiesStorePort | None = None):
16
+ self._llm = llm
17
+ self._workers = workers
18
+ self._store = store
19
+
20
+ @property
21
+ def location(self) -> str:
22
+ return self._store.location if self._store else ""
23
+
24
+ def load(self) -> list[Capability] | None:
25
+ """The capabilities file's list (with `plumbing`), None if there is no file. Raises
26
+ `CapabilitiesFileError` naming every problem in a file that breaks the rules."""
27
+ return load(self._store) if self._store else None
28
+
29
+ def exists(self) -> bool:
30
+ """Whether there is a file at all, usable or not."""
31
+ if self._store is None:
32
+ return False
33
+ try:
34
+ return self._store.read() is not None
35
+ except CapabilitiesFileError:
36
+ return True
37
+
38
+ def save(self, capabilities: list[Capability]) -> None:
39
+ if self._store is None:
40
+ raise ValueError("no capabilities file is configured")
41
+ self._store.write(capabilities)
42
+
43
+ def orient(self, observations: list[Observation]) -> list[Capability]:
44
+ return orient(observations, self._llm)
45
+
46
+ def assign(self, observations: list[Observation], capabilities: list[Capability],
47
+ earlier: dict[str, str] | None = None) -> dict[str, str]:
48
+ return assign(observations, capabilities, self._llm, self._workers, earlier)
@@ -0,0 +1,21 @@
1
+ """What capabilities needs from the outside world: somewhere to keep the owner's capability list."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Protocol
5
+
6
+ from backspec.modules.capabilities.domain import Capability
7
+
8
+
9
+ class CapabilitiesStorePort(Protocol):
10
+ @property
11
+ def location(self) -> str:
12
+ """Where the list lives, for messages."""
13
+ ...
14
+
15
+ def read(self) -> object | None:
16
+ """The parsed content of the stored list, or None when nothing is stored there.
17
+ Content that cannot be parsed at all raises `CapabilitiesFileError`."""
18
+ ...
19
+
20
+ def write(self, capabilities: list[Capability]) -> None:
21
+ ...
@@ -0,0 +1,63 @@
1
+ """Orient: propose capabilities from the observations. Assign: place every observation in one."""
2
+ from __future__ import annotations
3
+
4
+ from concurrent.futures import ThreadPoolExecutor
5
+
6
+ from backspec.kernel import prompts
7
+ from backspec.kernel.llm import LLMError, LLMPort, LLMRequest, ask
8
+ from backspec.modules.capabilities.domain import (UNASSIGNED, AssignReply, Capability, OrientReply, listing,
9
+ parse_capabilities, thin, with_plumbing)
10
+ from backspec.modules.capabilities.ports import CapabilitiesStorePort
11
+ from backspec.modules.evidence.facade import Observation
12
+
13
+ ORIENT_LIMIT = 2000 # observation lines one orient call is shown
14
+ ASSIGN_CHUNK = 280
15
+
16
+
17
+ def _items(observations: list[Observation]) -> list[tuple[str, str, str]]:
18
+ ordered = sorted(observations, key=lambda o: (o.path, o.lines[0]))
19
+ return [(o.id, o.path, o.statement) for o in ordered]
20
+
21
+
22
+ def orient(observations: list[Observation], llm: LLMPort) -> list[Capability]:
23
+ shown = listing(thin(_items(observations), ORIENT_LIMIT))
24
+ reply = ask(llm, LLMRequest("orient", f"{prompts.load('orient')}\n\n# The observations\n\n{shown}", "fast"), OrientReply)
25
+ return with_plumbing(list({c.id: c for c in reply.capabilities}.values()))
26
+
27
+
28
+ def load(store: CapabilitiesStorePort) -> list[Capability] | None:
29
+ """The owner's capability list, validated, or None when there is none. Refuses a bad file."""
30
+ data = store.read()
31
+ return None if data is None else parse_capabilities(data, store.location)
32
+
33
+
34
+ def assign(observations: list[Observation], capabilities: list[Capability], llm: LLMPort,
35
+ workers: int = 4, earlier: dict[str, str] | None = None) -> dict[str, str]:
36
+ """Observation id -> capability id. An observation the model skipped, or sent to a capability
37
+ that does not exist, is `unassigned`: it is still reported, never dropped.
38
+
39
+ An observation an earlier run placed in a capability that still exists stays there; only
40
+ the others are put to the model."""
41
+ known = {c.id for c in capabilities}
42
+ kept = {o.id: earlier[o.id] for o in observations
43
+ if earlier and earlier.get(o.id) in known and earlier[o.id] != UNASSIGNED}
44
+ everything, observations = observations, [o for o in observations if o.id not in kept]
45
+ described = "\n".join(f"- {c.id}: {c.name}. {c.summary} Includes: {'; '.join(c.includes)}" for c in capabilities)
46
+ items = _items(observations)
47
+ chunks = [items[start:start + ASSIGN_CHUNK] for start in range(0, len(items), ASSIGN_CHUNK)]
48
+ instructions = prompts.load("assign")
49
+
50
+ def run(chunk: list[tuple[str, str, str]]) -> dict[str, str]:
51
+ prompt = f"{instructions}\n\n# The capabilities\n\n{described}\n\n# The observations\n\n{listing(chunk)}"
52
+ try:
53
+ return ask(llm, LLMRequest("assign", prompt, "fast"), AssignReply).assignments
54
+ except LLMError:
55
+ return {}
56
+
57
+ with ThreadPoolExecutor(max_workers=max(1, workers)) as pool:
58
+ replies = list(pool.map(run, chunks))
59
+ placed: dict[str, str] = {}
60
+ for reply in replies:
61
+ placed.update(reply)
62
+ placed.update(kept)
63
+ return {o.id: placed[o.id] if placed.get(o.id) in known else UNASSIGNED for o in everything}
File without changes
@@ -0,0 +1,87 @@
1
+ """Which files are read, and why the others are not. Every file ends in exactly one state."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+ from functools import lru_cache
6
+ from typing import Literal
7
+
8
+ import pathspec
9
+
10
+ from backspec.kernel.text import sha256
11
+
12
+ DEFAULT_EXCLUDES = [
13
+ ".git/", "node_modules/", ".venv/", "venv/", "vendor/", "dist/", "build/", "__pycache__/",
14
+ "*.lock", "package-lock.json", "*.min.js", "*.map", ".env*", "*.pem", "*.key",
15
+ "*.png", "*.jpg", "*.jpeg", "*.gif", "*.ico", "*.pdf", "*.woff", "*.woff2", "*.zip",
16
+ "/backspec.capabilities.yaml", "/backspec.review.yaml", # backspec's own files are never read as source
17
+ ]
18
+ MAX_BYTES = 1_000_000
19
+ GENERATED_MARKERS = ("DO NOT EDIT", "@generated", "Code generated by")
20
+
21
+ Reason = Literal["default_exclude", "not_included", "exclude_pattern",
22
+ "binary", "too_large", "not_utf8", "generated"]
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class SourceFile:
27
+ """A file as it was when the run started. Every later stage reads this, never the disk."""
28
+ path: str
29
+ text: str
30
+ source_hash: str
31
+
32
+ @property
33
+ def lines(self) -> list[str]:
34
+ return self.text.split("\n")
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class Left:
39
+ """A file that was not read, with the state it ended in and why."""
40
+ path: str
41
+ state: Literal["excluded", "skipped"]
42
+ reason: Reason
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class Discovery:
47
+ files: tuple[SourceFile, ...]
48
+ left: tuple[Left, ...]
49
+
50
+
51
+ @lru_cache(maxsize=64)
52
+ def _compiled(patterns: tuple[str, ...]) -> pathspec.PathSpec:
53
+ try:
54
+ return pathspec.PathSpec.from_lines("gitignore", patterns)
55
+ except (KeyError, LookupError): # older pathspec knows the syntax by its former name
56
+ return pathspec.PathSpec.from_lines("gitwildmatch", patterns)
57
+
58
+
59
+ def _spec(patterns: list[str]) -> pathspec.PathSpec:
60
+ return _compiled(tuple(patterns))
61
+
62
+
63
+ def excluded_by_pattern(path: str, include: list[str], exclude: list[str],
64
+ default_excludes: bool = True) -> Reason | None:
65
+ """Why a path is out by pattern, or None. Layers only remove: exclude wins over include."""
66
+ if default_excludes and _spec(DEFAULT_EXCLUDES).match_file(path):
67
+ return "default_exclude"
68
+ if include and not _spec(include).match_file(path):
69
+ return "not_included"
70
+ if exclude and _spec(exclude).match_file(path):
71
+ return "exclude_pattern"
72
+ return None
73
+
74
+
75
+ def read_or_skip(path: str, data: bytes) -> SourceFile | Left:
76
+ """A file's snapshot, or the reason it cannot be read as source."""
77
+ if len(data) > MAX_BYTES:
78
+ return Left(path, "skipped", "too_large")
79
+ if b"\x00" in data[:8192]:
80
+ return Left(path, "skipped", "binary")
81
+ try:
82
+ text = data.decode("utf-8")
83
+ except UnicodeDecodeError:
84
+ return Left(path, "skipped", "not_utf8")
85
+ if any(marker in text[:600] for marker in GENERATED_MARKERS):
86
+ return Left(path, "skipped", "generated")
87
+ return SourceFile(path, text, sha256(text))
@@ -0,0 +1,16 @@
1
+ """Discovery, as the rest of backspec sees it."""
2
+ from __future__ import annotations
3
+
4
+ from backspec.modules.discovery.domain import Discovery, Left, SourceFile
5
+ from backspec.modules.discovery.ports import RepoPort
6
+ from backspec.modules.discovery.use_cases import discover
7
+
8
+ __all__ = ["Discovery", "DiscoveryFacade", "Left", "RepoPort", "SourceFile"]
9
+
10
+
11
+ class DiscoveryFacade:
12
+ def __init__(self, repo: RepoPort):
13
+ self._repo = repo
14
+
15
+ def discover(self, include: list[str], exclude: list[str], default_excludes: bool = True) -> Discovery:
16
+ return discover(self._repo, include, exclude, default_excludes)
@@ -0,0 +1,14 @@
1
+ """What discovery needs from the outside world."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Protocol
5
+
6
+
7
+ class RepoPort(Protocol):
8
+ def list_paths(self) -> list[str]:
9
+ """Every candidate file under the root, relative, with forward slashes, sorted.
10
+ Files that version control ignores are already left out."""
11
+ ...
12
+
13
+ def read(self, path: str) -> bytes:
14
+ ...
@@ -0,0 +1,19 @@
1
+ """Walk the repository once and take a snapshot of every file that will be read."""
2
+ from __future__ import annotations
3
+
4
+ from backspec.modules.discovery.domain import Discovery, Left, SourceFile, excluded_by_pattern, read_or_skip
5
+ from backspec.modules.discovery.ports import RepoPort
6
+
7
+
8
+ def discover(repo: RepoPort, include: list[str], exclude: list[str],
9
+ default_excludes: bool = True) -> Discovery:
10
+ files: list[SourceFile] = []
11
+ left: list[Left] = []
12
+ for path in repo.list_paths():
13
+ reason = excluded_by_pattern(path, include, exclude, default_excludes)
14
+ if reason:
15
+ left.append(Left(path, "excluded", reason))
16
+ continue
17
+ outcome = read_or_skip(path, repo.read(path))
18
+ (files if isinstance(outcome, SourceFile) else left).append(outcome) # type: ignore[arg-type]
19
+ return Discovery(tuple(files), tuple(left))
File without changes