blitz-cli 0.1.0__tar.gz → 0.4.0__tar.gz

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 (39) hide show
  1. blitz_cli-0.4.0/.github/workflows/homebrew.yml +54 -0
  2. blitz_cli-0.4.0/LICENSE +21 -0
  3. {blitz_cli-0.1.0 → blitz_cli-0.4.0}/PKG-INFO +8 -11
  4. blitz_cli-0.4.0/README.md +22 -0
  5. blitz_cli-0.4.0/blitz_cli/__init__.py +27 -0
  6. {blitz_cli-0.1.0 → blitz_cli-0.4.0}/blitz_cli/_client.py +30 -29
  7. blitz_cli-0.4.0/blitz_cli/_scaffold.py +103 -0
  8. blitz_cli-0.4.0/blitz_cli/_scan/__init__.py +65 -0
  9. blitz_cli-0.4.0/blitz_cli/_scan/_classify.py +28 -0
  10. blitz_cli-0.4.0/blitz_cli/_scan/_js.py +98 -0
  11. blitz_cli-0.4.0/blitz_cli/_scan/_models.py +126 -0
  12. blitz_cli-0.4.0/blitz_cli/_scan/_python.py +308 -0
  13. blitz_cli-0.4.0/blitz_cli/_scan/_report.py +196 -0
  14. blitz_cli-0.4.0/blitz_cli/_scan/_walk.py +68 -0
  15. blitz_cli-0.4.0/blitz_cli/_skill.py +59 -0
  16. blitz_cli-0.4.0/blitz_cli/cli.py +487 -0
  17. blitz_cli-0.4.0/blitz_cli/skills/__init__.py +0 -0
  18. blitz_cli-0.4.0/blitz_cli/skills/configure-training/SKILL.md +166 -0
  19. blitz_cli-0.4.0/blitz_cli/skills/onboard-to-blitz/SKILL.md +146 -0
  20. {blitz_cli-0.1.0 → blitz_cli-0.4.0}/blitz_cli/templates/Dockerfile.tmpl +1 -2
  21. {blitz_cli-0.1.0 → blitz_cli-0.4.0}/blitz_cli/templates/Makefile.tmpl +5 -7
  22. blitz_cli-0.4.0/blitz_cli/templates/README.md.tmpl +38 -0
  23. {blitz_cli-0.1.0 → blitz_cli-0.4.0}/blitz_cli/templates/dockerignore.tmpl +2 -0
  24. {blitz_cli-0.1.0 → blitz_cli-0.4.0}/blitz_cli/templates/requirements.txt.tmpl +1 -1
  25. blitz_cli-0.4.0/blitz_cli/templates/train.py.tmpl +216 -0
  26. {blitz_cli-0.1.0 → blitz_cli-0.4.0}/pyproject.toml +2 -1
  27. blitz_cli-0.4.0/tests/test_scaffold.py +89 -0
  28. blitz_cli-0.4.0/tests/test_scan.py +308 -0
  29. blitz_cli-0.1.0/README.md +0 -27
  30. blitz_cli-0.1.0/blitz_cli/__init__.py +0 -11
  31. blitz_cli-0.1.0/blitz_cli/_scaffold.py +0 -111
  32. blitz_cli-0.1.0/blitz_cli/cli.py +0 -128
  33. blitz_cli-0.1.0/blitz_cli/templates/README.md.tmpl +0 -36
  34. blitz_cli-0.1.0/blitz_cli/templates/eval.py.tmpl +0 -84
  35. blitz_cli-0.1.0/blitz_cli/templates/train.py.tmpl +0 -89
  36. blitz_cli-0.1.0/tests/test_scaffold.py +0 -80
  37. {blitz_cli-0.1.0 → blitz_cli-0.4.0}/.github/workflows/publish.yml +0 -0
  38. {blitz_cli-0.1.0 → blitz_cli-0.4.0}/.gitignore +0 -0
  39. {blitz_cli-0.1.0 → blitz_cli-0.4.0}/blitz_cli/templates/__init__.py +0 -0
@@ -0,0 +1,54 @@
1
+ name: Update Homebrew tap
2
+
3
+ # On every release tag, point the sparepartslabs/homebrew-tap formula at the new
4
+ # PyPI sdist. Runs alongside publish.yml (same v* trigger) and waits for the
5
+ # sdist to appear on PyPI before reading its canonical URL + sha256.
6
+ on:
7
+ push:
8
+ tags:
9
+ - "v*"
10
+
11
+ jobs:
12
+ bump:
13
+ runs-on: ubuntu-latest
14
+ steps:
15
+ - name: Resolve version
16
+ id: v
17
+ run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
18
+
19
+ - name: Fetch sdist url + sha256 from PyPI (wait for publish)
20
+ id: pypi
21
+ run: |
22
+ VERSION="${{ steps.v.outputs.version }}"
23
+ for i in $(seq 1 30); do
24
+ JSON=$(curl -fsSL "https://pypi.org/pypi/blitz-cli/${VERSION}/json" || true)
25
+ URL=$(printf '%s' "$JSON" | jq -r '.urls[]? | select(.packagetype=="sdist") | .url')
26
+ SHA=$(printf '%s' "$JSON" | jq -r '.urls[]? | select(.packagetype=="sdist") | .digests.sha256')
27
+ if [ -n "$URL" ] && [ "$URL" != "null" ]; then break; fi
28
+ echo "sdist for ${VERSION} not on PyPI yet, retrying ($i)..."
29
+ sleep 10
30
+ done
31
+ if [ -z "$URL" ] || [ "$URL" = "null" ]; then
32
+ echo "::error::blitz-cli ${VERSION} sdist not found on PyPI"; exit 1
33
+ fi
34
+ echo "url=$URL" >> "$GITHUB_OUTPUT"
35
+ echo "sha=$SHA" >> "$GITHUB_OUTPUT"
36
+
37
+ - name: Checkout tap
38
+ uses: actions/checkout@v4
39
+ with:
40
+ repository: sparepartslabs/homebrew-tap
41
+ token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
42
+
43
+ - name: Update formula
44
+ run: |
45
+ F=Formula/blitz-cli.rb
46
+ sed -i -E "s#^ url \".*\"# url \"${{ steps.pypi.outputs.url }}\"#" "$F"
47
+ sed -i -E "s#^ sha256 \".*\"# sha256 \"${{ steps.pypi.outputs.sha }}\"#" "$F"
48
+ git config user.name "github-actions[bot]"
49
+ git config user.email "github-actions[bot]@users.noreply.github.com"
50
+ if git diff --quiet; then
51
+ echo "formula already up to date"; exit 0
52
+ fi
53
+ git commit -am "blitz-cli ${{ steps.v.outputs.version }}"
54
+ git push
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Spare Parts Labs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -1,16 +1,17 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: blitz-cli
3
- Version: 0.1.0
3
+ Version: 0.4.0
4
4
  Summary: Developer CLI for Blitz: pull a workflow's training data + base-model recommendation and scaffold a runnable QLoRA training container.
5
+ License: MIT
6
+ License-File: LICENSE
5
7
  Requires-Python: >=3.9
6
8
  Description-Content-Type: text/markdown
7
9
 
8
10
  # blitz-cli
9
11
 
10
12
  Developer CLI for [Blitz](https://github.com/sparepartslabs/blitz-sdk-py). Pull a
11
- workflow's captured traces as a fine-tuning dataset + the recommended open base
12
- model, and scaffold a self-contained, runnable QLoRA training container — then
13
- close the loop by grading the trained student against the held-out eval set.
13
+ workflow's captured traces as a fine-tuning dataset, and scaffold a
14
+ self-contained, runnable QLoRA training container.
14
15
 
15
16
  ```bash
16
17
  pip install blitz-cli
@@ -19,16 +20,12 @@ export BLITZ_API_KEY=blz_... # a read-scoped project key (mint one in the dash
19
20
  blitz scaffold -p proj_abc -w mechanic-assistant -o ./train
20
21
 
21
22
  cd train
22
- make build && make train && make eval
23
+ make build && make train
23
24
  ```
24
25
 
25
- `blitz scaffold` writes `./train` with `data/dataset.jsonl`, `data/evalset.jsonl`,
26
- `config.json` (derived from the base-model recommendation), and a `Dockerfile` +
27
- `train.py` + `eval.py` + `Makefile`. Bring your own NVIDIA GPU.
26
+ `blitz scaffold` writes `./train` with `data/dataset.jsonl` and a `Dockerfile` +
27
+ `train.py` + `Makefile`. Bring your own NVIDIA GPU.
28
28
 
29
29
  This package is intentionally dependency-free (stdlib + the Blitz HTTP API). The
30
30
  heavy ML stack (torch / transformers / trl / peft) is pinned only in the
31
31
  *generated* project's `requirements.txt`, installed inside the training image.
32
-
33
- The complementary tracing SDK (`pip install blitz-sdk`, `import blitz`) lives in
34
- [`../blitz-sdk`](../blitz-sdk).
@@ -0,0 +1,22 @@
1
+ # blitz-cli
2
+
3
+ Developer CLI for [Blitz](https://github.com/sparepartslabs/blitz-sdk-py). Pull a
4
+ workflow's captured traces as a fine-tuning dataset, and scaffold a
5
+ self-contained, runnable QLoRA training container.
6
+
7
+ ```bash
8
+ pip install blitz-cli
9
+
10
+ export BLITZ_API_KEY=blz_... # a read-scoped project key (mint one in the dashboard)
11
+ blitz scaffold -p proj_abc -w mechanic-assistant -o ./train
12
+
13
+ cd train
14
+ make build && make train
15
+ ```
16
+
17
+ `blitz scaffold` writes `./train` with `data/dataset.jsonl` and a `Dockerfile` +
18
+ `train.py` + `Makefile`. Bring your own NVIDIA GPU.
19
+
20
+ This package is intentionally dependency-free (stdlib + the Blitz HTTP API). The
21
+ heavy ML stack (torch / transformers / trl / peft) is pinned only in the
22
+ *generated* project's `requirements.txt`, installed inside the training image.
@@ -0,0 +1,27 @@
1
+ """blitz-cli — onboard a codebase to Blitz and scaffold a QLoRA trainer.
2
+
3
+ Commands:
4
+
5
+ - ``blitz init -p <project>`` — write a repo-local ``.env`` (BLITZ_PROJECT /
6
+ BLITZ_ENDPOINT) and validate your ingest key.
7
+
8
+ - ``blitz scan [path]`` — statically walk a codebase and report each AI/LLM
9
+ call's Blitz instrumentation posture (unsupported provider / no blitz.init() /
10
+ traced-but-unnamed / instrumented), with copy-paste fixes. Offline, no API key.
11
+
12
+ - ``blitz verify [--wait]`` — confirm sample traces have landed in Blitz,
13
+ authenticated by the ingest key (``BLITZ_API_KEY``).
14
+
15
+ - ``blitz skill install`` — drop the Blitz Claude Code skills (``onboard-to-blitz``,
16
+ ``configure-training``) into the repo's ``.claude/skills`` so Claude can run the
17
+ onboarding and training playbooks.
18
+
19
+ - ``blitz scaffold -p <project> -w <workflow> -o ./train`` — download a
20
+ workflow's SFT dataset, then emit a self-contained, runnable training project
21
+ (Dockerfile + train.py). Authenticates with a read-scoped Blitz API key
22
+ (``BLITZ_API_KEY``).
23
+ """
24
+
25
+ __all__ = ["__version__"]
26
+
27
+ __version__ = "0.4.0"
@@ -26,8 +26,9 @@ class BlitzAPIError(RuntimeError):
26
26
  def _hint(status: int, detail: str) -> str:
27
27
  if status in (401, 403):
28
28
  return (
29
- f"{detail} (HTTP {status}). Check BLITZ_API_KEY — it must be a "
30
- "read-scoped key for this project (create one in the dashboard)."
29
+ f"{detail} (HTTP {status}). Check BLITZ_API_KEY — it must be a valid "
30
+ "key for this project (an ingest key for tracing/verify, a read key "
31
+ "for training data). Create one in the dashboard."
31
32
  )
32
33
  if status == 404:
33
34
  return (
@@ -71,6 +72,12 @@ class BlitzClient:
71
72
  except Exception: # noqa: BLE001
72
73
  pass
73
74
  raise BlitzAPIError(exc.code, _hint(exc.code, str(detail))) from None
75
+ except urllib.error.URLError as exc:
76
+ raise BlitzAPIError(
77
+ 0,
78
+ f"could not reach the Blitz API at {self._base} ({exc.reason}). "
79
+ "Check BLITZ_ENDPOINT and your network.",
80
+ ) from None
74
81
 
75
82
  def _get_json(self, path: str, query: Optional[dict] = None) -> dict:
76
83
  req = urllib.request.Request(self._url(path, query), headers=self._headers)
@@ -85,30 +92,34 @@ class BlitzClient:
85
92
  if line:
86
93
  yield json.loads(line)
87
94
 
88
- def _post_json(self, path: str, body: dict) -> dict:
95
+ def _send_json(self, path: str, body: dict, method: str) -> dict:
89
96
  data = json.dumps(body).encode("utf-8")
90
97
  req = urllib.request.Request(
91
98
  self._url(path),
92
99
  data=data,
93
- method="POST",
100
+ method=method,
94
101
  headers={**self._headers, "content-type": "application/json"},
95
102
  )
96
103
  with self._open(req) as resp:
97
104
  return json.loads(resp.read().decode("utf-8"))
98
105
 
99
- # -- public API (one method per endpoint the CLI / eval loop needs) -----
106
+ def _post_json(self, path: str, body: dict) -> dict:
107
+ return self._send_json(path, body, "POST")
108
+
109
+ def _put_json(self, path: str, body: dict) -> dict:
110
+ return self._send_json(path, body, "PUT")
111
+
112
+ # -- public API (one method per endpoint the CLI needs) -----------------
100
113
 
101
114
  def _p(self, suffix: str) -> str:
102
115
  return f"/blitz/projects/{self._project}{suffix}"
103
116
 
104
- def recommended_base(self, workflow: str) -> dict:
105
- return self._get_json(self._p("/recommended-base"), {"workflow": workflow})
106
-
107
- def recommended_tools(self, workflow: str) -> dict:
108
- return self._get_json(self._p("/recommended-tools"), {"workflow": workflow})
117
+ def get_training_config(self, workflow: str) -> dict:
118
+ return self._get_json(self._p("/training-config"), {"workflow": workflow})
109
119
 
110
- def eval_summary(self, workflow: str) -> dict:
111
- return self._get_json(self._p("/eval-set/summary"), {"workflow": workflow})
120
+ def set_training_config(self, config: dict) -> dict:
121
+ # config is the full flat config (includes `workflow`); the server keys on it.
122
+ return self._put_json(self._p("/training-config"), config)
112
123
 
113
124
  def download_dataset(
114
125
  self, workflow: Optional[str] = None, include_synthetic: bool = True
@@ -118,21 +129,11 @@ class BlitzClient:
118
129
  {"workflow": workflow, "include_synthetic": include_synthetic},
119
130
  )
120
131
 
121
- def download_eval_set(self, workflow: Optional[str] = None) -> Iterator[dict]:
122
- return self._get_ndjson(self._p("/eval-dataset"), {"workflow": workflow})
123
-
124
- def submit_eval(
125
- self, workflow: str, predictions: list, grader: str = "auto"
126
- ) -> dict:
127
- return self._post_json(
128
- self._p("/eval"),
129
- {
130
- "workflow": workflow,
131
- "candidate": "supplied",
132
- "grader": grader,
133
- "predictions": predictions,
134
- },
135
- )
132
+ def training_readiness(self, workflow: str) -> dict:
133
+ return self._get_json(self._p("/training-readiness"), {"workflow": workflow})
136
134
 
137
- def get_eval_run(self, run_id: str) -> dict:
138
- return self._get_json(self._p(f"/eval/runs/{run_id}"))
135
+ def ingest_status(self, workflow: Optional[str] = None) -> dict:
136
+ """Trace summary for `blitz verify`, authed by the ingest key. This is
137
+ the one read the ingest scope can do (path is not project-scoped; the
138
+ key resolves the project server-side)."""
139
+ return self._get_json("/blitz/v1/ingest-status", {"workflow": workflow})
@@ -0,0 +1,103 @@
1
+ """Render a runnable QLoRA training project + derive a training config.
2
+
3
+ Templates live in ``blitz_cli/templates`` and ship as package data. ``train.py``
4
+ and ``requirements.txt`` are copied verbatim (they read ``config.json`` at
5
+ runtime, so they need no substitution and stay valid Python regardless of the
6
+ chosen base model); ``Dockerfile``/``Makefile``/``README`` get ``$var`` substitution
7
+ via string.Template (avoids the brace-escaping pain of str.format on code).
8
+
9
+ The config itself is no longer written into the project at scaffold time. The
10
+ onboarding skill assembles it (via ``build_config``) and pushes it to Blitz with
11
+ ``blitz config set``; the training container fetches it at run start and writes its
12
+ own ``config.json``. ``build_config`` stays here as the shared deriver that turns a
13
+ base-model choice into a full config with sensible, size-scaled hyperparameters.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from importlib import resources
19
+ from pathlib import Path
20
+ from string import Template
21
+
22
+
23
+ def clamp_seq_len(raw: int) -> int:
24
+ """Clamp a raw sequence length (p95_input + max_output) to the trainer's
25
+ supported range. Defaults to 2048 when nothing usable is supplied."""
26
+ return max(1024, min(8192, int(raw or 0) or 2048))
27
+
28
+
29
+ def build_config(
30
+ workflow: str,
31
+ base_model_hf: str,
32
+ params_b: float = 7.0,
33
+ license: str = "",
34
+ seq_len: int = 2048,
35
+ ) -> dict:
36
+ """Derive a full QLoRA training config from a base-model choice.
37
+
38
+ Hyperparameters scale with model size to keep a single 24GB-class card viable
39
+ (effective batch ~16-32). ``seq_len`` is clamped to the trainer's range.
40
+ """
41
+ params_b = float(params_b or 7)
42
+
43
+ if params_b <= 3:
44
+ lora_r, batch, accum, lr = 16, 4, 4, 2e-4
45
+ elif params_b <= 9:
46
+ lora_r, batch, accum, lr = 16, 2, 8, 2e-4
47
+ else: # 14B and up
48
+ lora_r, batch, accum, lr = 32, 1, 16, 1e-4
49
+
50
+ return {
51
+ "workflow": workflow,
52
+ "base_model_hf": base_model_hf,
53
+ "params_b": params_b,
54
+ "license": license or "",
55
+ "seq_len": clamp_seq_len(seq_len),
56
+ "lora_r": lora_r,
57
+ "lora_alpha": lora_r * 2,
58
+ "lora_dropout": 0.05,
59
+ "epochs": 3,
60
+ "lr": lr,
61
+ "per_device_batch_size": batch,
62
+ "grad_accum": accum,
63
+ }
64
+
65
+
66
+ def _template(name: str) -> str:
67
+ return resources.files("blitz_cli.templates").joinpath(name).read_text(
68
+ encoding="utf-8"
69
+ )
70
+
71
+
72
+ def _write(path: Path, content: str) -> None:
73
+ path.write_text(content, encoding="utf-8")
74
+
75
+
76
+ def render(out_dir: Path, project: str, workflow: str, endpoint: str) -> None:
77
+ """Write the training project skeleton into out_dir.
78
+
79
+ No config.json is written: the container fetches the config from Blitz at run
80
+ start (see the templates). Only project/workflow/endpoint are substituted, all
81
+ into the Dockerfile/Makefile/README wiring.
82
+ """
83
+ out_dir.mkdir(parents=True, exist_ok=True)
84
+
85
+ # Verbatim (read config.json at runtime) — keep as real source files.
86
+ for name in ("train.py", "requirements.txt", "dockerignore"):
87
+ dest = ".dockerignore" if name == "dockerignore" else name
88
+ _write(out_dir / dest, _template(name + ".tmpl"))
89
+
90
+ subs = {"project": project, "workflow": workflow, "endpoint": endpoint}
91
+ for name in ("Dockerfile", "Makefile", "README.md"):
92
+ _write(out_dir / name, Template(_template(name + ".tmpl")).safe_substitute(subs))
93
+
94
+
95
+ def gated_license_warning(license: str) -> str | None:
96
+ """A warning string if a base model's license needs HF acceptance, else None."""
97
+ lic = (license or "").lower()
98
+ if lic.startswith("llama") or lic.startswith("gemma"):
99
+ return (
100
+ f"This base model is gated ({lic}). Accept its license on Hugging Face "
101
+ "and pass HF_TOKEN into the container (see README) before training."
102
+ )
103
+ return None
@@ -0,0 +1,65 @@
1
+ """Codebase scan: find AI/LLM calls and report their Blitz instrumentation
2
+ posture. Public surface: ``scan_path``, ``render_report``, ``dumps_json``."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from pathlib import Path
7
+ from typing import Optional, Set
8
+
9
+ from ._classify import classify
10
+ from ._js import scan_js
11
+ from ._models import ProjectSignals, ScanResult, Tier
12
+ from ._python import scan_python
13
+ from ._report import dumps_json, render_report
14
+ from ._walk import iter_source_files
15
+
16
+ __all__ = ["scan_path", "render_report", "dumps_json", "ScanResult", "Tier"]
17
+
18
+
19
+ def scan_path(root: Path, langs: Optional[Set[str]] = None) -> ScanResult:
20
+ """Walk ``root``, detect LLM calls in each file, then classify every site
21
+ against tree-wide signals (whether the project calls ``blitz.init()``)."""
22
+ langs = langs or {"python", "js"}
23
+ root = root.resolve()
24
+ base = root if root.is_dir() else root.parent
25
+
26
+ result = ScanResult(root=str(root))
27
+ signals = ProjectSignals()
28
+
29
+ for path, lang in iter_source_files(root, langs):
30
+ try:
31
+ source = path.read_text(encoding="utf-8", errors="replace")
32
+ except OSError:
33
+ continue
34
+ rel = _relpath(path, base)
35
+ try:
36
+ scan = scan_python(path, source, rel) if lang == "python" else scan_js(
37
+ path, source, rel
38
+ )
39
+ except SyntaxError:
40
+ result.skipped.append(rel)
41
+ continue
42
+ signals.files_scanned += 1
43
+ if scan.has_init:
44
+ if lang == "python":
45
+ signals.has_python_init = True
46
+ else:
47
+ signals.has_js_init = True
48
+ result.sites.extend(scan.sites)
49
+
50
+ # Classification needs the tree-wide init signal, so it runs after the walk.
51
+ result.signals = signals
52
+ for site in result.sites:
53
+ classify(site, signals)
54
+
55
+ # Stable ordering: worst tier first, then path/line.
56
+ order = {Tier.UNSUPPORTED: 0, Tier.NO_INIT: 1, Tier.UNNAMED: 2, Tier.INSTRUMENTED: 3}
57
+ result.sites.sort(key=lambda s: (order.get(s.tier, 9), s.path, s.line))
58
+ return result
59
+
60
+
61
+ def _relpath(path: Path, base: Path) -> str:
62
+ try:
63
+ return path.resolve().relative_to(base).as_posix()
64
+ except ValueError:
65
+ return path.as_posix()
@@ -0,0 +1,28 @@
1
+ """The 4-tier decision tree. Pure and language-agnostic: both detectors funnel
2
+ their ``CallSite`` drafts through here once project-wide signals are known."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from ._models import CallSite, Confidence, ProjectSignals, Provider, SUPPORTED, Tier
7
+
8
+
9
+ def classify(site: CallSite, signals: ProjectSignals) -> None:
10
+ """Set ``site.tier`` (and adjust confidence) in place."""
11
+ if site.in_untraced:
12
+ # Intentional suppression (blitz-platform meta-calls). Not a miss; we
13
+ # still label it INSTRUMENTED-equivalent so it drops out of misses().
14
+ site.tier = Tier.INSTRUMENTED
15
+ return
16
+
17
+ if site.provider not in SUPPORTED:
18
+ site.tier = Tier.UNSUPPORTED
19
+ elif not signals.has_init(site.lang):
20
+ site.tier = Tier.NO_INIT
21
+ elif not site.in_named_workflow:
22
+ site.tier = Tier.UNNAMED
23
+ # A decorated caller further up the stack may already be tracing this
24
+ # helper at runtime — we can't see that lexically, so soften confidence.
25
+ if site.confidence is Confidence.HIGH:
26
+ site.confidence = Confidence.MEDIUM
27
+ else:
28
+ site.tier = Tier.INSTRUMENTED
@@ -0,0 +1,98 @@
1
+ """JS/TS detector — honest regex heuristic.
2
+
3
+ There's no stdlib JS parser and blitz-cli must stay dependency-free, so this is
4
+ line-oriented ``re`` matching, not real parsing. Consequences, stated loudly:
5
+ no scope resolution, no reliable client-var/decorator tracking, and — because a
6
+ JS Blitz SDK's ``init`` can't be verified here — supported-provider JS calls are
7
+ reported as NO_INIT at LOW confidence rather than pretending they're traced.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from pathlib import Path
14
+ from typing import List, Optional
15
+
16
+ from ._models import CallSite, Confidence, FileScan, Provider
17
+
18
+ # import specifier substring -> provider (used to know what a file touches)
19
+ _IMPORT_PROVIDER = [
20
+ ("@anthropic-ai/sdk", Provider.ANTHROPIC),
21
+ ("@google/generative-ai", Provider.GEMINI),
22
+ ("@google/genai", Provider.GEMINI),
23
+ ("openai", Provider.OPENAI),
24
+ ("@langchain", Provider.LANGCHAIN),
25
+ ("langchain", Provider.LANGCHAIN),
26
+ ("litellm", Provider.LITELLM),
27
+ ("cohere-ai", Provider.COHERE),
28
+ ("@mistralai", Provider.MISTRAL),
29
+ ("@aws-sdk/client-bedrock", Provider.BEDROCK),
30
+ ("ollama", Provider.OLLAMA),
31
+ ("groq-sdk", Provider.GROQ),
32
+ ]
33
+
34
+ # call-chain regex -> provider it identifies
35
+ _CALL_PATTERNS = [
36
+ (re.compile(r"\.messages\.create\s*\("), Provider.ANTHROPIC),
37
+ (re.compile(r"\.messages\.stream\s*\("), Provider.ANTHROPIC),
38
+ (re.compile(r"\.chat\.completions\.create\s*\("), Provider.OPENAI),
39
+ (re.compile(r"\.responses\.create\s*\("), Provider.OPENAI),
40
+ (re.compile(r"\.generateContent\s*\("), Provider.GEMINI),
41
+ (re.compile(r"\.generateContentStream\s*\("), Provider.GEMINI),
42
+ ]
43
+
44
+ _INIT_RE = re.compile(r"blitz\.init\s*\(")
45
+ _WORKFLOW_RE = re.compile(r"blitz\.workflow\s*\(")
46
+
47
+
48
+ def scan_js(path: Path, source: str, rel_path: str) -> FileScan:
49
+ lines = source.splitlines()
50
+ imported: List[Provider] = []
51
+ has_init = False
52
+ for ln in lines:
53
+ if _INIT_RE.search(ln):
54
+ has_init = True
55
+ for spec, prov in _IMPORT_PROVIDER:
56
+ if ("import" in ln or "require" in ln) and spec in ln and prov not in imported:
57
+ imported.append(prov)
58
+
59
+ sites: List[CallSite] = []
60
+ for i, ln in enumerate(lines, start=1):
61
+ for pat, prov in _CALL_PATTERNS:
62
+ m = pat.search(ln)
63
+ if not m:
64
+ continue
65
+ # Prefer a provider actually imported in this file; a bare
66
+ # `.responses.create` in a groq/azure file still reads as its shape.
67
+ provider = prov
68
+ sites.append(
69
+ CallSite(
70
+ path=rel_path,
71
+ line=i,
72
+ provider=provider,
73
+ lang="js",
74
+ call_expr=m.group(0).rstrip("(").strip(),
75
+ enclosing_func=_nearest_func(lines, i - 1),
76
+ in_named_workflow=bool(_WORKFLOW_RE.search(ln)),
77
+ in_untraced=False,
78
+ confidence=Confidence.LOW,
79
+ )
80
+ )
81
+ return FileScan(sites=sites, has_init=has_init)
82
+
83
+
84
+ _FUNC_RES = [
85
+ re.compile(r"function\s+([A-Za-z0-9_$]+)"),
86
+ re.compile(r"(?:const|let|var)\s+([A-Za-z0-9_$]+)\s*=\s*(?:async\s*)?\("),
87
+ re.compile(r"([A-Za-z0-9_$]+)\s*\([^)]*\)\s*\{"),
88
+ ]
89
+
90
+
91
+ def _nearest_func(lines: List[str], idx: int) -> Optional[str]:
92
+ """Best-effort: the nearest preceding function-ish declaration name."""
93
+ for j in range(idx, max(-1, idx - 60), -1):
94
+ for rx in _FUNC_RES:
95
+ m = rx.search(lines[j])
96
+ if m:
97
+ return m.group(1)
98
+ return None