foretop-ebb 0.1.0__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 (61) hide show
  1. ebb/__init__.py +1 -0
  2. ebb/__main__.py +4 -0
  3. ebb/brand.py +5 -0
  4. ebb/build_findings.py +118 -0
  5. ebb/canonicalize.py +60 -0
  6. ebb/cli.py +176 -0
  7. ebb/detect/__init__.py +0 -0
  8. ebb/detect/base.py +29 -0
  9. ebb/detect/dockerfile.py +8 -0
  10. ebb/detect/json_format.py +8 -0
  11. ebb/detect/notebook.py +25 -0
  12. ebb/detect/patterns.py +51 -0
  13. ebb/detect/python.py +8 -0
  14. ebb/detect/registry.py +25 -0
  15. ebb/detect/scan_text.py +46 -0
  16. ebb/detect/terraform.py +8 -0
  17. ebb/detect/toml.py +8 -0
  18. ebb/detect/typescript.py +8 -0
  19. ebb/detect/yaml.py +8 -0
  20. ebb/finding.py +34 -0
  21. ebb/owner.py +52 -0
  22. ebb/registries/retirements/anthropic.yaml +101 -0
  23. ebb/registries/retirements/bedrock.yaml +81 -0
  24. ebb/registries/retirements/cohere.yaml +33 -0
  25. ebb/registries/retirements/google.yaml +408 -0
  26. ebb/registries/retirements/openai.yaml +107 -0
  27. ebb/registry/__init__.py +0 -0
  28. ebb/registry/loader.py +74 -0
  29. ebb/registry/models.py +47 -0
  30. ebb/registry/resolve.py +36 -0
  31. ebb/render/__init__.py +0 -0
  32. ebb/render/annotations.py +50 -0
  33. ebb/render/json_renderer.py +7 -0
  34. ebb/render/markdown.py +41 -0
  35. ebb/render/sarif.py +11 -0
  36. ebb/render/terminal.py +31 -0
  37. ebb/triage.py +166 -0
  38. ebb/triage_role.py +415 -0
  39. ebb/verdict.py +48 -0
  40. ebb/walk.py +38 -0
  41. foretop_ebb-0.1.0.dist-info/METADATA +175 -0
  42. foretop_ebb-0.1.0.dist-info/RECORD +61 -0
  43. foretop_ebb-0.1.0.dist-info/WHEEL +4 -0
  44. foretop_ebb-0.1.0.dist-info/entry_points.txt +4 -0
  45. foretop_ebb-0.1.0.dist-info/licenses/LICENSE +202 -0
  46. keel/__init__.py +1 -0
  47. keel/collect/__init__.py +0 -0
  48. keel/collect/walk.py +95 -0
  49. keel/finding.py +105 -0
  50. keel/gate.py +152 -0
  51. keel/git_ref.py +45 -0
  52. keel/owner.py +58 -0
  53. keel/render/__init__.py +0 -0
  54. keel/render/annotations.py +62 -0
  55. keel/render/markdown.py +40 -0
  56. keel/render/sarif.py +85 -0
  57. keel/render/terminal.py +38 -0
  58. keel/render/triage_json.py +15 -0
  59. keel/report.py +98 -0
  60. keel/run.py +67 -0
  61. keel/triage.py +167 -0
ebb/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
ebb/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from ebb.cli import app
2
+
3
+ if __name__ == "__main__":
4
+ app()
ebb/brand.py ADDED
@@ -0,0 +1,5 @@
1
+ BRAND = "foretop"
2
+ """The insulation rule (NEXT_STEPS.md): until the trademark search clears, the brand name lives
3
+ in exactly this one constant — package prefix, CLI namespace, user-facing strings — and nowhere
4
+ else. A rename is then a find-replace, a re-publish, and a GitHub org rename, not a week of
5
+ hunting down hardcoded strings."""
ebb/build_findings.py ADDED
@@ -0,0 +1,118 @@
1
+ import hashlib
2
+ import uuid
3
+ from datetime import UTC, date, datetime
4
+ from pathlib import Path
5
+
6
+ from ebb import __version__ as ENGINE_VERSION # noqa: N812
7
+ from ebb.canonicalize import canonicalize
8
+ from ebb.detect.base import RawMatch
9
+ from ebb.finding import Evidence, Finding, FindingState, ProductCode, Verdict
10
+ from ebb.owner import find_owner
11
+ from ebb.registry.loader import Registry
12
+ from ebb.registry.models import RegistryEntry
13
+ from ebb.registry.resolve import Resolution, Resolved, resolve
14
+ from ebb.verdict import compute_confidence, compute_severity, compute_verdict
15
+ from ebb.walk import scan_repo
16
+
17
+ RULE_ID = "ebb.retirement"
18
+
19
+
20
+ def compute_identity(canonical_id: str, relative_path: str, symbol: str) -> str:
21
+ """Canonical model id + file path + normalized surrounding symbol — deliberately excludes
22
+ the line number, per SUITE_ARCHITECTURE.md §3.1: identity must survive cosmetic reformatting
23
+ and break on real change. A hash rather than a readable string because identity is compared,
24
+ never displayed — title/detail_md carry the human-facing description."""
25
+ raw = f"ebb|{canonical_id}|{relative_path}|{symbol}"
26
+ return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]
27
+
28
+
29
+ def _title_for(canonical_id: str, verdict: Verdict, entry: RegistryEntry | None) -> str:
30
+ if verdict is Verdict.BREAK:
31
+ assert entry is not None and entry.shutdown_at is not None
32
+ return f"{canonical_id} retired {entry.shutdown_at.isoformat()}"
33
+ if verdict is Verdict.DRIFT:
34
+ assert entry is not None and entry.shutdown_at is not None
35
+ return f"{canonical_id} retires {entry.shutdown_at.isoformat()}"
36
+ if verdict is Verdict.CLEAR:
37
+ return f"{canonical_id} is actively supported"
38
+ return f"{canonical_id} is not in the tracked registry"
39
+
40
+
41
+ def _detail_md_for(canonical_id: str, entry: RegistryEntry | None) -> str:
42
+ if entry is None:
43
+ return (
44
+ f"`{canonical_id}` was detected but is not a verified entry in the retirement "
45
+ "registry. This means unknown, not confirmed safe."
46
+ )
47
+ parts = [f"Provider: **{entry.provider.value}**", f"Source: {entry.source_url}"]
48
+ if entry.announced_at:
49
+ parts.append(f"Announced: {entry.announced_at.isoformat()}")
50
+ if entry.shutdown_at:
51
+ parts.append(f"Shutdown: {entry.shutdown_at.isoformat()}")
52
+ if entry.replacement_id:
53
+ parts.append(f"Replacement: `{entry.replacement_id}`")
54
+ return "\n".join(f"- {p}" for p in parts)
55
+
56
+
57
+ def build_finding(
58
+ match: RawMatch,
59
+ *,
60
+ repo_root: Path,
61
+ registry: Registry,
62
+ run_id: str,
63
+ today: date | None = None,
64
+ ) -> Finding:
65
+ today = today or datetime.now(UTC).date()
66
+ canonical_id = canonicalize(match.matched_text)
67
+ resolution: Resolution = resolve(canonical_id, registry)
68
+ entry = resolution.entry if isinstance(resolution, Resolved) else None
69
+
70
+ verdict = compute_verdict(resolution, today)
71
+ severity = compute_severity(verdict, resolution, today)
72
+ confidence = compute_confidence(resolution)
73
+
74
+ relative_path = str(match.path.relative_to(repo_root))
75
+ owner = find_owner(match.path, match.line, repo_root)
76
+ content_hash = hashlib.sha256(match.path.read_bytes()).hexdigest()
77
+
78
+ evidence = Evidence(
79
+ source_uri=relative_path,
80
+ locator=str(match.line),
81
+ excerpt=match.matched_text,
82
+ retrieved_at=datetime.now(UTC),
83
+ content_hash=content_hash,
84
+ )
85
+
86
+ return Finding(
87
+ identity=compute_identity(canonical_id, relative_path, match.symbol),
88
+ product=ProductCode.EBB,
89
+ rule_id=RULE_ID,
90
+ subject=canonical_id,
91
+ severity=severity,
92
+ confidence=confidence,
93
+ title=_title_for(canonical_id, verdict, entry),
94
+ detail_md=_detail_md_for(canonical_id, entry),
95
+ evidence=[evidence],
96
+ owner=owner,
97
+ deadline=entry.shutdown_at if entry else None,
98
+ verdict=verdict,
99
+ # No baseline mechanism exists for ebb's stateless local CLI yet — every finding from a
100
+ # single `ebb scan` invocation is NEW. See FindingState's own docstring.
101
+ state=FindingState.NEW,
102
+ first_seen_run=run_id,
103
+ rule_version=registry.version,
104
+ engine_version=ENGINE_VERSION,
105
+ )
106
+
107
+
108
+ def build_findings(
109
+ repo_root: Path, registry: Registry, *, today: date | None = None
110
+ ) -> list[Finding]:
111
+ """The full pipeline, wired together for the first time: walk -> detect -> canonicalize ->
112
+ resolve -> owner attribution -> verdict/severity/confidence -> Finding. One run_id shared
113
+ across every finding from this invocation."""
114
+ run_id = f"run-{uuid.uuid4()}"
115
+ return [
116
+ build_finding(match, repo_root=repo_root, registry=registry, run_id=run_id, today=today)
117
+ for match in scan_repo(repo_root)
118
+ ]
ebb/canonicalize.py ADDED
@@ -0,0 +1,60 @@
1
+ import re
2
+
3
+ # Collapses infrastructure-wrapper syntax down to the bare model id a provider's own API would
4
+ # use directly, so the same underlying model is recognized as the same canonical id regardless
5
+ # of which cloud layer referenced it. Every pattern here is verified against live vendor docs
6
+ # (CLAUDE_CODE_PLAN.md's "never write a contract from memory" applies to this too):
7
+ #
8
+ # - AWS Bedrock cross-Region inference profile IDs are exactly "{region}.{vendor}.{model-id}",
9
+ # e.g. `us.anthropic.claude-3-haiku-20240307-v1:0` — this literal example is drawn straight
10
+ # from AWS's own inference-profiles-support docs, not invented.
11
+ # - Bedrock (profile or plain) model IDs carry a trailing "-v{N}:{M}" version suffix.
12
+ # - GCP Vertex AI publisher-model resource names are "publishers/{publisher}/models/{model}",
13
+ # optionally prefixed "projects/{project}/locations/{location}/" — confirmed against Google's
14
+ # own REST reference for the publisherModels resource.
15
+ # - Vertex AI model *versions* use a trailing "@{N}" suffix — this is GCP's general resource
16
+ # versioning convention (used across several GCP APIs), not confirmed against a Claude-
17
+ # specific example, so treat it as a well-hedged structural rule rather than a cited fact.
18
+ #
19
+ # Deliberately NOT handled here: stripping "-latest" / "-preview" style suffixes. Some of those
20
+ # are curated as their own whole registry entries (e.g. `chatgpt-4o-latest`, which OpenAI
21
+ # deprecated as an alias in its own right — see src/ebb/registries/retirements/openai.yaml).
22
+ # Stripping the suffix here would silently break that entry's resolution. A floating alias
23
+ # that ISN'T itself curated stays exactly as unresolvable after canonicalization as it was
24
+ # before — see resolve()'s tests (Session 4) for why that's correct, not a gap.
25
+ _BEDROCK_REGION_PREFIX = re.compile(r"^(us|eu|apac|us-gov)\.")
26
+ _BEDROCK_VENDOR_PREFIX = re.compile(
27
+ r"^(anthropic|amazon|meta|mistral|cohere|ai21|openai|deepseek|writer)\."
28
+ )
29
+ _BEDROCK_VERSION_SUFFIX = re.compile(r"(-v\d+)?:\d+$")
30
+ _VERTEX_VERSION_SUFFIX = re.compile(r"@\d+$")
31
+ _VERTEX_RESOURCE_PATH = re.compile(
32
+ r"^(?:projects/[^/]+/locations/[^/]+/)?publishers/[^/]+/models/(?P<model_id>.+)$"
33
+ )
34
+
35
+
36
+ def canonicalize(raw_text: str) -> str:
37
+ """Pure and isolated, per CLAUDE_CODE_PLAN.md Session 5 — no registry lookup, no I/O, no
38
+ dependency on anything but its own input. Idempotent (canonicalize(canonicalize(x)) ==
39
+ canonicalize(x) for all x) and round-trips (any of this module's recognized wrapper forms
40
+ of a given model canonicalizes to the same result as the model's own bare id) — both
41
+ verified by the property tests in tests/unit/test_canonicalize.py.
42
+ """
43
+ text = raw_text.strip()
44
+
45
+ vertex_match = _VERTEX_RESOURCE_PATH.match(text)
46
+ if vertex_match:
47
+ text = vertex_match.group("model_id")
48
+
49
+ text = _VERTEX_VERSION_SUFFIX.sub("", text)
50
+ text = _BEDROCK_VERSION_SUFFIX.sub("", text)
51
+
52
+ region_match = _BEDROCK_REGION_PREFIX.match(text)
53
+ if region_match:
54
+ text = text[region_match.end() :]
55
+
56
+ vendor_match = _BEDROCK_VENDOR_PREFIX.match(text)
57
+ if vendor_match:
58
+ text = text[vendor_match.end() :]
59
+
60
+ return text
ebb/cli.py ADDED
@@ -0,0 +1,176 @@
1
+ import sys
2
+ from datetime import UTC, datetime
3
+ from importlib import resources
4
+ from pathlib import Path
5
+
6
+ import typer
7
+ from keel.gate import GateFetchError, gate_identities, maybe_fetch_gate, summarize
8
+ from keel.render.triage_json import render_triage_json
9
+ from keel.report import ReportError, maybe_report
10
+ from keel.triage import TriageResult
11
+ from rich.console import Console
12
+
13
+ from ebb.brand import BRAND
14
+ from ebb.build_findings import build_findings
15
+ from ebb.finding import Finding, Severity
16
+ from ebb.registry.loader import RegistryLoadError, load_registry
17
+ from ebb.render.annotations import render_annotations
18
+ from ebb.render.json_renderer import render_json
19
+ from ebb.render.markdown import render_markdown
20
+ from ebb.render.sarif import render_sarif
21
+ from ebb.render.terminal import render_terminal
22
+ from ebb.triage import build_triage
23
+
24
+ _FORMATS = ("table", "markdown", "json", "sarif", "annotations", "triage-json")
25
+
26
+ app = typer.Typer(
27
+ name="ebb", help=f"{BRAND} ebb — find model identifiers before a provider retires them."
28
+ )
29
+ console = Console()
30
+ error_console = Console(stderr=True)
31
+
32
+ _SEVERITY_ORDER = [
33
+ Severity.INFO,
34
+ Severity.LOW,
35
+ Severity.MEDIUM,
36
+ Severity.HIGH,
37
+ Severity.CRITICAL,
38
+ ]
39
+
40
+
41
+ @app.callback()
42
+ def _callback() -> None:
43
+ """Keeps `scan` addressable as `ebb scan PATH` — without a callback, Typer collapses an
44
+ app with a single command into a bare `ebb PATH` invocation instead."""
45
+
46
+
47
+ def _default_registry_dir() -> Path:
48
+ # Packaged as data inside the `ebb` distribution (src/ebb/registries/retirements/) so
49
+ # `uvx foretop-ebb scan .` works on a cold machine with no monorepo checkout in sight
50
+ # (specs/ebb.md §9's definition of done) — importlib.resources resolves relative to
51
+ # wherever the installed package actually lives, whether that's an editable workspace
52
+ # checkout or a normal site-packages install. --registry-dir still overrides this for
53
+ # anyone pinning to their own fork of the data (specs/ebb.md §5: the registry becomes a
54
+ # versioned dependency of its own eventually — this is the seam that day reuses).
55
+ return Path(str(resources.files("ebb") / "registries" / "retirements"))
56
+
57
+
58
+ def _render(findings: list[Finding], fmt: str, *, triage: TriageResult | None = None) -> str:
59
+ if fmt == "table":
60
+ return render_terminal(findings, no_color=not sys.stdout.isatty())
61
+ if fmt == "markdown":
62
+ return render_markdown(findings)
63
+ if fmt == "json":
64
+ return render_json(findings)
65
+ if fmt == "sarif":
66
+ import json
67
+
68
+ return json.dumps(render_sarif(findings), indent=2, sort_keys=True)
69
+ if fmt == "annotations":
70
+ return render_annotations(findings)
71
+ if fmt == "triage-json":
72
+ assert triage is not None # guaranteed by the fmt == "triage-json" caller-side guard
73
+ return render_triage_json(triage)
74
+ raise ValueError(f"unknown format: {fmt!r}")
75
+
76
+
77
+ @app.command()
78
+ def scan(
79
+ path: Path = typer.Argument(Path("."), help="Repository path to scan."), # noqa: B008
80
+ fmt: str = typer.Option(
81
+ "table",
82
+ "--format",
83
+ help="Output format: table, markdown, json, sarif, annotations, or triage-json.",
84
+ ),
85
+ registry_dir: Path | None = typer.Option( # noqa: B008
86
+ None,
87
+ "--registry-dir",
88
+ help="Directory of registry YAML files. Defaults to this repo's registries/retirements/.",
89
+ ),
90
+ fail_on: str | None = typer.Option(
91
+ None,
92
+ "--fail-on",
93
+ help="Exit 1 if any finding's severity is at or above this level "
94
+ "(info, low, medium, high, critical). Unset: always exit 0.",
95
+ ),
96
+ report: bool = typer.Option(
97
+ False,
98
+ "--report",
99
+ help="Send this run's findings to the hub (FORETOP_TOKEN required). Off by default. "
100
+ "Always prints the exact payload before sending — findings are metadata only, "
101
+ "never your source.",
102
+ ),
103
+ gate: bool = typer.Option(
104
+ False,
105
+ "--gate",
106
+ help="Fetch the org's baseline/suppressions from the hub (FORETOP_TOKEN required) and "
107
+ "exclude matching findings from --fail-on's decision. Off by default; a no-op unless "
108
+ "--fail-on is also given.",
109
+ ),
110
+ ) -> None:
111
+ """Scan a repository for model references and report their retirement status.
112
+
113
+ Exit codes are an API (specs/ebb.md §6): 0 clean or below --fail-on's threshold, 1 a
114
+ finding met or exceeded --fail-on, 2 an internal error (bad --format, registry failed to
115
+ load). These must never be conflated.
116
+ """
117
+ if fmt not in _FORMATS:
118
+ error_console.print(f"Unknown --format {fmt!r}. Choose one of: {', '.join(_FORMATS)}.")
119
+ raise typer.Exit(code=2)
120
+
121
+ threshold: Severity | None = None
122
+ if fail_on is not None:
123
+ try:
124
+ threshold = Severity(fail_on)
125
+ except ValueError:
126
+ error_console.print(
127
+ f"Unknown --fail-on {fail_on!r}. Choose one of: "
128
+ f"{', '.join(s.value for s in _SEVERITY_ORDER)}."
129
+ )
130
+ raise typer.Exit(code=2) from None
131
+
132
+ registry_paths = sorted((registry_dir or _default_registry_dir()).glob("*.yaml"))
133
+ try:
134
+ registry = load_registry(registry_paths)
135
+ except RegistryLoadError as exc:
136
+ error_console.print(f"Registry failed to load: {exc}")
137
+ raise typer.Exit(code=2) from exc
138
+
139
+ findings = build_findings(path.resolve(), registry)
140
+ # Computed lazily, shared between --format triage-json and --report: build_triage is not
141
+ # free (classify_usage_role re-reads and re-parses every matched source file), so a plain
142
+ # scan with neither flag must never pay for it, and giving both flags at once must not pay
143
+ # for it twice.
144
+ triage = (
145
+ build_triage(findings, registry=registry, repo_root=path.resolve())
146
+ if fmt == "triage-json" or report
147
+ else None
148
+ )
149
+ print(_render(findings, fmt, triage=triage))
150
+
151
+ try:
152
+ maybe_report(enabled=report, product="ebb", path=path, findings=findings, triage=triage)
153
+ except ReportError as exc:
154
+ error_console.print(str(exc))
155
+ raise typer.Exit(code=2) from exc
156
+
157
+ if threshold is not None:
158
+ threshold_rank = _SEVERITY_ORDER.index(threshold)
159
+ candidates = {
160
+ f.identity for f in findings if _SEVERITY_ORDER.index(f.severity) >= threshold_rank
161
+ }
162
+ # --gate is a no-op both when unset and when there's nothing --fail-on would flag
163
+ # anyway — no network call in either case, matching --report's own offline-by-default
164
+ # posture (MARKETING_BRIEF.md: "runs offline, no account").
165
+ if gate and candidates:
166
+ try:
167
+ gate_response = maybe_fetch_gate(enabled=True)
168
+ except GateFetchError as exc:
169
+ error_console.print(str(exc))
170
+ raise typer.Exit(code=2) from exc
171
+ assert gate_response is not None # enabled=True always returns a GateResponse
172
+ result = gate_identities(candidates, gate_response, today=datetime.now(UTC).date())
173
+ print(summarize(result))
174
+ candidates -= result.excluded
175
+ if candidates:
176
+ raise typer.Exit(code=1)
ebb/detect/__init__.py ADDED
File without changes
ebb/detect/base.py ADDED
@@ -0,0 +1,29 @@
1
+ from dataclasses import dataclass
2
+ from pathlib import Path
3
+ from typing import Protocol
4
+
5
+
6
+ @dataclass(frozen=True, slots=True)
7
+ class RawMatch:
8
+ """One model-identifier-shaped string found in a file.
9
+
10
+ Line numbers exist for human-readable output only — per SUITE_ARCHITECTURE.md §3.1, line
11
+ numbers must never enter finding identity. `symbol` is the "normalized surrounding symbol"
12
+ identity actually uses instead: the identifier immediately governing the matched text (an
13
+ assignment target, a JSON/YAML/TOML key, a Dockerfile ENV/ARG name), captured at detection
14
+ time since that's when the full line text is already in hand. See
15
+ ebb.detect.scan_text.nearest_symbol for exactly what "normalized" means here.
16
+ """
17
+
18
+ path: Path
19
+ line: int
20
+ matched_text: str
21
+ symbol: str
22
+
23
+
24
+ class Detector(Protocol):
25
+ """A pure function `(path, content) -> list[RawMatch]`. No I/O, no side effects — the
26
+ walker reads file content once and hands it to every detector registered for that file's
27
+ kind. Adding a language means adding one detector and registering it in registry.py."""
28
+
29
+ def __call__(self, path: Path, content: str) -> list[RawMatch]: ...
@@ -0,0 +1,8 @@
1
+ from pathlib import Path
2
+
3
+ from ebb.detect.base import RawMatch
4
+ from ebb.detect.scan_text import scan_lines
5
+
6
+
7
+ def detect(path: Path, content: str) -> list[RawMatch]:
8
+ return scan_lines(path, content)
@@ -0,0 +1,8 @@
1
+ from pathlib import Path
2
+
3
+ from ebb.detect.base import RawMatch
4
+ from ebb.detect.scan_text import scan_lines
5
+
6
+
7
+ def detect(path: Path, content: str) -> list[RawMatch]:
8
+ return scan_lines(path, content)
ebb/detect/notebook.py ADDED
@@ -0,0 +1,25 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ from ebb.detect.base import RawMatch
5
+ from ebb.detect.scan_text import scan_lines
6
+
7
+
8
+ def detect(path: Path, content: str) -> list[RawMatch]:
9
+ """Scans every cell's source (code and markdown alike — a model id in a doc cell is still
10
+ worth surfacing; ranking it belongs to later sessions, not detection). A notebook that
11
+ fails to parse as JSON yields no matches rather than crashing the walk — one malformed
12
+ .ipynb must never take down a scan of the other 499 files in the repo."""
13
+ try:
14
+ notebook = json.loads(content)
15
+ except json.JSONDecodeError:
16
+ return []
17
+
18
+ matches: list[RawMatch] = []
19
+ for cell in notebook.get("cells", []):
20
+ source = cell.get("source", "")
21
+ cell_text = source if isinstance(source, str) else "".join(source)
22
+ # Line numbers are cell-relative, not file-relative — a notebook has no single
23
+ # meaningful line axis across cells. Informational only, as everywhere in Slice 1.
24
+ matches.extend(scan_lines(path, cell_text))
25
+ return matches
ebb/detect/patterns.py ADDED
@@ -0,0 +1,51 @@
1
+ import re
2
+
3
+ # Structural patterns, not an enumerated list: DEC-06 (specs/ebb.md §1) commits to three
4
+ # providers "deeply correct" at launch — OpenAI, Anthropic, Google. Each pattern matches the
5
+ # provider's naming *shape* (including older `claude-3-opus-20240229`-style and newer
6
+ # `claude-opus-4-8`-style Anthropic ids — both conventions exist in the wild) so a model
7
+ # released after this file was written still matches, without needing a code change. This is
8
+ # deliberately looser than the versioned registry (Session 4), which pins exact known-valid ids
9
+ # with source_url/verified_at; Slice 1 only needs "shaped like a model identifier".
10
+ # OpenAI dates ship two conventions: a full ISO date (`-2024-05-13`) and a bare 4-digit code,
11
+ # optionally trailed by `-preview` (`-0125`, `-1106-preview`) — both need to be optional and
12
+ # tried in this order so the longer ISO form isn't shadowed by the shorter one matching a
13
+ # prefix of it.
14
+ _OPENAI_DATE_SUFFIX = r"(?:-\d{4}-\d{2}-\d{2}|-\d{4}(?:-preview)?)"
15
+ # `4\.\d+` (gpt-4.1, gpt-4.1-mini) must come before bare `4` in the alternation: alternatives
16
+ # are tried in order and the first one that lets the overall match succeed wins, so with `4`
17
+ # first, "gpt-4.1" would match only "gpt-4" and silently drop the ".1" that makes it a
18
+ # different, real model. Found via the Session 7 golden corpus, not guessed.
19
+ _OPENAI = (
20
+ r"\bgpt-(?:4o|4\.\d+|4|3\.5|5(?:\.\d+)?)(?:-turbo|-mini|-nano)?" + _OPENAI_DATE_SUFFIX + r"?\b"
21
+ )
22
+ _OPENAI_O_SERIES = r"\bo[134](?:-preview|-mini|-pro)?" + _OPENAI_DATE_SUFFIX + r"?\b"
23
+ _OPENAI_EMBEDDING = r"\btext-embedding-(?:ada-002|3-small|3-large)\b"
24
+ _OPENAI_OTHER = r"\b(?:dall-e-[23]|whisper-1)\b"
25
+
26
+ _ANTHROPIC_DATED = (
27
+ r"\bclaude-\d(?:[.-]\d)?(?:-(?:opus|sonnet|haiku|instant))?(?:-\d{8})?(?:-latest)?\b"
28
+ )
29
+ # Tier-before-version: covers both the old `claude-instant-1.2` (dotted) and the current
30
+ # `claude-opus-4-8` / `claude-haiku-4-5-20251001` (hyphenated) conventions.
31
+ _ANTHROPIC_NAMED = r"\bclaude-(?:opus|sonnet|haiku|fable|instant)-\d+(?:[.-]\d+)?(?:-\d{8})?\b"
32
+
33
+ _GOOGLE_GEMINI = (
34
+ r"\bgemini-\d(?:\.\d)?(?:-pro|-flash|-ultra)?(?:-vision)?(?:-\d{3})?(?:-preview|-exp)?\b"
35
+ )
36
+ _GOOGLE_BISON = r"\b(?:text|chat)-bison(?:-00[12])?\b"
37
+
38
+ MODEL_ID_PATTERN = re.compile(
39
+ "|".join(
40
+ [
41
+ _OPENAI,
42
+ _OPENAI_O_SERIES,
43
+ _OPENAI_EMBEDDING,
44
+ _OPENAI_OTHER,
45
+ _ANTHROPIC_DATED,
46
+ _ANTHROPIC_NAMED,
47
+ _GOOGLE_GEMINI,
48
+ _GOOGLE_BISON,
49
+ ]
50
+ )
51
+ )
ebb/detect/python.py ADDED
@@ -0,0 +1,8 @@
1
+ from pathlib import Path
2
+
3
+ from ebb.detect.base import RawMatch
4
+ from ebb.detect.scan_text import scan_lines
5
+
6
+
7
+ def detect(path: Path, content: str) -> list[RawMatch]:
8
+ return scan_lines(path, content)
ebb/detect/registry.py ADDED
@@ -0,0 +1,25 @@
1
+ from pathlib import Path
2
+
3
+ from ebb.detect import dockerfile, json_format, notebook, python, terraform, toml, typescript, yaml
4
+ from ebb.detect.base import Detector
5
+
6
+ _BY_EXTENSION: dict[str, Detector] = {
7
+ ".py": python.detect,
8
+ ".ts": typescript.detect,
9
+ ".tsx": typescript.detect,
10
+ ".yml": yaml.detect,
11
+ ".yaml": yaml.detect,
12
+ ".toml": toml.detect,
13
+ ".json": json_format.detect,
14
+ ".tf": terraform.detect,
15
+ ".ipynb": notebook.detect,
16
+ }
17
+
18
+
19
+ def get_detector(path: Path) -> Detector | None:
20
+ """Adding a language: add one file under detect/, one entry here (or one more filename
21
+ rule below for extension-less formats), and one fixture under tests/fixtures/. Nothing
22
+ else in the walker or CLI needs to change."""
23
+ if path.name == "Dockerfile" or path.name.startswith("Dockerfile."):
24
+ return dockerfile.detect
25
+ return _BY_EXTENSION.get(path.suffix)
@@ -0,0 +1,46 @@
1
+ import re
2
+ from pathlib import Path
3
+
4
+ from ebb.detect.base import RawMatch
5
+ from ebb.detect.patterns import MODEL_ID_PATTERN
6
+
7
+ # The identifier immediately before the nearest '=' or ':' preceding a match on its own line —
8
+ # covers Python/TypeScript assignments (MODEL = "..."), JSON/YAML/TOML keys ("model": "...",
9
+ # model: ...), and Dockerfile ENV/ARG forms (ENV MODEL=...). Language-agnostic on purpose: a
10
+ # real per-language symbol table (AST-scoped to the enclosing function/class) would be far more
11
+ # precise, but is a much bigger undertaking than this session's scope — this heuristic is
12
+ # "good enough to keep identity stable across reformatting," which is the actual requirement
13
+ # (SUITE_ARCHITECTURE.md §3.1), not "semantically perfect."
14
+ _ASSIGNMENT_PATTERN = re.compile(r"([A-Za-z_][A-Za-z0-9_.\-]*)[\"']?\s*[:=]")
15
+ _MAX_FALLBACK_SYMBOL_LENGTH = 40
16
+
17
+
18
+ def nearest_symbol(line: str, match_start: int) -> str:
19
+ """Never empty: falls back to a bounded prefix of the line itself (e.g. a bare string in a
20
+ list has no assignment to name it), and finally to "unknown" for a blank line — every match
21
+ gets a stable, non-empty symbol so identity never depends on an absent one."""
22
+ prefix = line[:match_start]
23
+ candidates = list(_ASSIGNMENT_PATTERN.finditer(prefix))
24
+ if candidates:
25
+ return candidates[-1].group(1)
26
+ stripped = line.strip()
27
+ return stripped[:_MAX_FALLBACK_SYMBOL_LENGTH] if stripped else "unknown"
28
+
29
+
30
+ def scan_lines(path: Path, content: str) -> list[RawMatch]:
31
+ """Line-oriented regex scan shared by every format where a model id appears as plain text
32
+ inside otherwise-structured content (source code, YAML/TOML/JSON values, Terraform,
33
+ Dockerfiles). One RawMatch per occurrence — no dedup here, that's identity's job later
34
+ (SUITE_ARCHITECTURE.md §3.1), not the raw-match layer's."""
35
+ matches: list[RawMatch] = []
36
+ for lineno, line in enumerate(content.splitlines(), start=1):
37
+ for m in MODEL_ID_PATTERN.finditer(line):
38
+ matches.append(
39
+ RawMatch(
40
+ path=path,
41
+ line=lineno,
42
+ matched_text=m.group(0),
43
+ symbol=nearest_symbol(line, m.start()),
44
+ )
45
+ )
46
+ return matches
@@ -0,0 +1,8 @@
1
+ from pathlib import Path
2
+
3
+ from ebb.detect.base import RawMatch
4
+ from ebb.detect.scan_text import scan_lines
5
+
6
+
7
+ def detect(path: Path, content: str) -> list[RawMatch]:
8
+ return scan_lines(path, content)
ebb/detect/toml.py ADDED
@@ -0,0 +1,8 @@
1
+ from pathlib import Path
2
+
3
+ from ebb.detect.base import RawMatch
4
+ from ebb.detect.scan_text import scan_lines
5
+
6
+
7
+ def detect(path: Path, content: str) -> list[RawMatch]:
8
+ return scan_lines(path, content)
@@ -0,0 +1,8 @@
1
+ from pathlib import Path
2
+
3
+ from ebb.detect.base import RawMatch
4
+ from ebb.detect.scan_text import scan_lines
5
+
6
+
7
+ def detect(path: Path, content: str) -> list[RawMatch]:
8
+ return scan_lines(path, content)
ebb/detect/yaml.py ADDED
@@ -0,0 +1,8 @@
1
+ from pathlib import Path
2
+
3
+ from ebb.detect.base import RawMatch
4
+ from ebb.detect.scan_text import scan_lines
5
+
6
+
7
+ def detect(path: Path, content: str) -> list[RawMatch]:
8
+ return scan_lines(path, content)
ebb/finding.py ADDED
@@ -0,0 +1,34 @@
1
+ """Re-export shim: the Finding schema moved to keel.finding in Session 26.
2
+
3
+ SUITE_ARCHITECTURE.md §3 always called this "the keystone: everything flows from this" and
4
+ listed all five products in ProductCode — but the module lived here, in ebb, and four of five
5
+ products never emitted a Finding at all. Session 26 moved it to packages/keel so the other
6
+ four can adopt it one at a time (telltale first), because every paid tier on the pricing page
7
+ is a query across products and that needs one shape in one table.
8
+
9
+ This shim keeps `from ebb.finding import ...` working unchanged for ebb's own eight importing
10
+ modules — same approach apps/telltale/src/telltale/walk.py (Session 10) and
11
+ apps/undertow-crawler/src/undertow_crawler/fetch.py (Session 20) already use for their own
12
+ extractions. ebb's behaviour did not change; its full test suite passing untouched is the
13
+ proof.
14
+ """
15
+
16
+ from keel.finding import (
17
+ Confidence,
18
+ Evidence,
19
+ Finding,
20
+ FindingState,
21
+ ProductCode,
22
+ Severity,
23
+ Verdict,
24
+ )
25
+
26
+ __all__ = [
27
+ "Confidence",
28
+ "Evidence",
29
+ "Finding",
30
+ "FindingState",
31
+ "ProductCode",
32
+ "Severity",
33
+ "Verdict",
34
+ ]