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/kernel/usage.py
ADDED
|
@@ -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
|