blitz-cli 0.1.0__tar.gz → 0.3.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 (34) hide show
  1. blitz_cli-0.3.0/.github/workflows/homebrew.yml +54 -0
  2. blitz_cli-0.3.0/LICENSE +21 -0
  3. {blitz_cli-0.1.0 → blitz_cli-0.3.0}/PKG-INFO +3 -1
  4. blitz_cli-0.3.0/blitz_cli/__init__.py +26 -0
  5. {blitz_cli-0.1.0 → blitz_cli-0.3.0}/blitz_cli/_client.py +15 -2
  6. blitz_cli-0.3.0/blitz_cli/_scan/__init__.py +65 -0
  7. blitz_cli-0.3.0/blitz_cli/_scan/_classify.py +28 -0
  8. blitz_cli-0.3.0/blitz_cli/_scan/_js.py +98 -0
  9. blitz_cli-0.3.0/blitz_cli/_scan/_models.py +126 -0
  10. blitz_cli-0.3.0/blitz_cli/_scan/_python.py +308 -0
  11. blitz_cli-0.3.0/blitz_cli/_scan/_report.py +196 -0
  12. blitz_cli-0.3.0/blitz_cli/_scan/_walk.py +68 -0
  13. blitz_cli-0.3.0/blitz_cli/_skill.py +42 -0
  14. blitz_cli-0.3.0/blitz_cli/cli.py +383 -0
  15. blitz_cli-0.3.0/blitz_cli/skills/__init__.py +0 -0
  16. blitz_cli-0.3.0/blitz_cli/skills/onboard-to-blitz/SKILL.md +140 -0
  17. {blitz_cli-0.1.0 → blitz_cli-0.3.0}/blitz_cli/templates/Makefile.tmpl +1 -1
  18. {blitz_cli-0.1.0 → blitz_cli-0.3.0}/blitz_cli/templates/eval.py.tmpl +21 -0
  19. blitz_cli-0.3.0/blitz_cli/templates/train.py.tmpl +184 -0
  20. {blitz_cli-0.1.0 → blitz_cli-0.3.0}/pyproject.toml +2 -1
  21. blitz_cli-0.3.0/tests/test_scan.py +308 -0
  22. blitz_cli-0.1.0/blitz_cli/__init__.py +0 -11
  23. blitz_cli-0.1.0/blitz_cli/cli.py +0 -128
  24. blitz_cli-0.1.0/blitz_cli/templates/train.py.tmpl +0 -89
  25. {blitz_cli-0.1.0 → blitz_cli-0.3.0}/.github/workflows/publish.yml +0 -0
  26. {blitz_cli-0.1.0 → blitz_cli-0.3.0}/.gitignore +0 -0
  27. {blitz_cli-0.1.0 → blitz_cli-0.3.0}/README.md +0 -0
  28. {blitz_cli-0.1.0 → blitz_cli-0.3.0}/blitz_cli/_scaffold.py +0 -0
  29. {blitz_cli-0.1.0 → blitz_cli-0.3.0}/blitz_cli/templates/Dockerfile.tmpl +0 -0
  30. {blitz_cli-0.1.0 → blitz_cli-0.3.0}/blitz_cli/templates/README.md.tmpl +0 -0
  31. {blitz_cli-0.1.0 → blitz_cli-0.3.0}/blitz_cli/templates/__init__.py +0 -0
  32. {blitz_cli-0.1.0 → blitz_cli-0.3.0}/blitz_cli/templates/dockerignore.tmpl +0 -0
  33. {blitz_cli-0.1.0 → blitz_cli-0.3.0}/blitz_cli/templates/requirements.txt.tmpl +0 -0
  34. {blitz_cli-0.1.0 → blitz_cli-0.3.0}/tests/test_scaffold.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,7 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: blitz-cli
3
- Version: 0.1.0
3
+ Version: 0.3.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
 
@@ -0,0 +1,26 @@
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 ``onboard-to-blitz`` Claude Code skill into
16
+ the repo's ``.claude/skills`` so Claude can run the onboarding playbook.
17
+
18
+ - ``blitz scaffold -p <project> -w <workflow> -o ./train`` — download a
19
+ workflow's SFT dataset, held-out eval set, and base-model recommendation, then
20
+ emit a self-contained, runnable training project (Dockerfile + train.py +
21
+ eval.py). Authenticates with a read-scoped Blitz API key (``BLITZ_API_KEY``).
22
+ """
23
+
24
+ __all__ = ["__version__"]
25
+
26
+ __version__ = "0.3.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)
@@ -136,3 +143,9 @@ class BlitzClient:
136
143
 
137
144
  def get_eval_run(self, run_id: str) -> dict:
138
145
  return self._get_json(self._p(f"/eval/runs/{run_id}"))
146
+
147
+ def ingest_status(self, workflow: Optional[str] = None) -> dict:
148
+ """Trace summary for `blitz verify`, authed by the ingest key. This is
149
+ the one read the ingest scope can do (path is not project-scoped; the
150
+ key resolves the project server-side)."""
151
+ return self._get_json("/blitz/v1/ingest-status", {"workflow": workflow})
@@ -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
@@ -0,0 +1,126 @@
1
+ """Data model for the codebase scan: providers, tiers, and per-call records.
2
+
3
+ Everything here is stdlib-only (``dataclasses`` + ``enum``) so the whole scan
4
+ subpackage stays dependency-free like the rest of blitz-cli. The detectors
5
+ (``_python``/``_js``) emit ``CallSite`` drafts; ``_classify`` fills in ``tier``;
6
+ ``_report`` renders them.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+ from enum import Enum
13
+ from typing import Dict, List, Optional
14
+
15
+
16
+ class Provider(str, Enum):
17
+ """Every LLM provider we can name. Only ``SUPPORTED`` ones are auto-traced
18
+ by ``blitz.init()``; the rest are invisible to Blitz today."""
19
+
20
+ OPENAI = "openai"
21
+ ANTHROPIC = "anthropic"
22
+ GEMINI = "gemini"
23
+ LANGCHAIN = "langchain"
24
+ PYDANTIC_AI = "pydantic-ai"
25
+ LITELLM = "litellm"
26
+ COHERE = "cohere"
27
+ MISTRAL = "mistral"
28
+ BEDROCK = "bedrock"
29
+ VERTEX = "vertex"
30
+ OLLAMA = "ollama"
31
+ GROQ = "groq"
32
+ UNKNOWN = "unknown"
33
+
34
+
35
+ # Providers whose calls blitz traces after a single blitz.init().
36
+ # - openai / anthropic / gemini: directly auto-instrumented via OpenLLMetry.
37
+ # - pydantic-ai: a thin typed wrapper that always calls the official
38
+ # openai/anthropic/gemini SDKs at the exact methods OpenLLMetry patches
39
+ # (verified: the pinned anthropic instrumentor wraps beta.messages.AsyncMessages
40
+ # .create, which is what pydantic-ai's AnthropicModel calls). So it IS traced
41
+ # once init runs — the gate is init, not the framework. (Caveat: a pydantic-ai
42
+ # model over a non-supported provider, e.g. groq/mistral, would not be traced.)
43
+ SUPPORTED = {
44
+ Provider.OPENAI,
45
+ Provider.ANTHROPIC,
46
+ Provider.GEMINI,
47
+ Provider.PYDANTIC_AI,
48
+ }
49
+
50
+
51
+ class Tier(str, Enum):
52
+ """Instrumentation posture of a single AI call, worst-first when reported."""
53
+
54
+ UNSUPPORTED = "unsupported" # provider blitz can't auto-trace at all
55
+ NO_INIT = "no_init" # supported, but the project never calls blitz.init()
56
+ UNNAMED = "unnamed" # traced, but not under a named workflow()
57
+ INSTRUMENTED = "instrumented" # supported + init + under a workflow()
58
+
59
+
60
+ class Confidence(str, Enum):
61
+ HIGH = "high" # python ast, lexical facts certain
62
+ MEDIUM = "medium" # python ast, but the call-graph caveat may apply
63
+ LOW = "low" # js/ts regex heuristic
64
+
65
+
66
+ @dataclass
67
+ class CallSite:
68
+ """One detected AI/LLM call and everything we know about how it's wrapped."""
69
+
70
+ path: str # relative to the scan root, posix-style
71
+ line: int
72
+ provider: Provider
73
+ lang: str # "python" | "js"
74
+ call_expr: str # e.g. "client.messages.create"
75
+ enclosing_func: Optional[str] = None # for the workflow-name suggestion
76
+ in_named_workflow: bool = False # lexical `with blitz.workflow` OR decorator
77
+ in_untraced: bool = False # inside `with _untraced():` — suppressed on purpose
78
+ confidence: Confidence = Confidence.HIGH
79
+ tier: Optional[Tier] = None # filled by _classify
80
+
81
+
82
+ @dataclass
83
+ class ProjectSignals:
84
+ """Tree-wide facts that decide NO_INIT vs UNNAMED for the whole scan."""
85
+
86
+ has_python_init: bool = False # `blitz.init(` resolved anywhere in the .py tree
87
+ has_js_init: bool = False # best-effort; a JS SDK may not exist yet
88
+ files_scanned: int = 0
89
+
90
+ def has_init(self, lang: str) -> bool:
91
+ return self.has_python_init if lang == "python" else self.has_js_init
92
+
93
+
94
+ @dataclass
95
+ class FileScan:
96
+ """What a single-file detector returns before project-wide aggregation."""
97
+
98
+ sites: List[CallSite] = field(default_factory=list)
99
+ has_init: bool = False
100
+
101
+
102
+ @dataclass
103
+ class ScanResult:
104
+ root: str
105
+ signals: ProjectSignals = field(default_factory=ProjectSignals)
106
+ sites: List[CallSite] = field(default_factory=list)
107
+ skipped: List[str] = field(default_factory=list) # files we couldn't parse
108
+
109
+ def misses(self) -> List[CallSite]:
110
+ """Sites a user should act on: anything not fully instrumented, minus the
111
+ intentionally-suppressed ``_untraced()`` calls."""
112
+ return [
113
+ s
114
+ for s in self.sites
115
+ if not s.in_untraced and s.tier is not Tier.INSTRUMENTED
116
+ ]
117
+
118
+ def by_tier(self) -> Dict[Tier, List[CallSite]]:
119
+ out: Dict[Tier, List[CallSite]] = {t: [] for t in Tier}
120
+ for s in self.sites:
121
+ if s.tier is not None:
122
+ out[s.tier].append(s)
123
+ return out
124
+
125
+ def untraced(self) -> List[CallSite]:
126
+ return [s for s in self.sites if s.in_untraced]