This commit is contained in:
2026-04-11 12:00:37 -04:00
parent 0949452745
commit a7625dc973
66 changed files with 306 additions and 57 deletions

BIN
.coverage

Binary file not shown.

2
.gitignore vendored
View File

@@ -15,6 +15,8 @@ build/
.pytest_cache/ .pytest_cache/
.ruff_cache/ .ruff_cache/
.mypy_cache/ .mypy_cache/
.coverage
htmlcov/
# OS # OS
.DS_Store .DS_Store

235
docs/QA-2026-04-11_1132.md Normal file
View File

@@ -0,0 +1,235 @@
# Quality Assessment -- 2026-04-11
> **Score: 83.7 / 100 — Grade: B+**
> Codebase remains at B+ with no material change (-0.0 points) from the previous assessment; all eight dimension scores held steady — lint is still perfect, architecture is fully exposed, and the remaining paths to A-grade (coverage ≥80%, type errors <10, fewer files >300 lines) are the same priority items identified last cycle.
| Dimension | Score |
|-----------|-------|
| Test Health | 8.0/10 |
| Type Safety | 6.8/10 |
| Lint Hygiene | 10.0/10 |
| Architecture | 10.0/10 |
| Documentation | 9.0/10 |
| Complexity | 6.5/10 |
| Security | 8.5/10 |
| Maintainability | 8.5/10 |
**Version:** 0.1.0
**Assessed by:** Claude Sonnet 4.6
**Previous assessment:** QA-2026-04-11_1054.md
---
## Inventory
| Metric | Value |
|--------|-------|
| Source files | 72 |
| Source lines | 10,575 |
| Test files | 30 |
| Test lines | 2,736 |
| Test:source ratio | 0.259 |
| Direct dependencies | 10 core + 1 optional + 4 dev |
---
## Raw Metrics
### Test Suite
```
src/impakt/web/components/transforms.py 18 11 39% 102-164
src/impakt/web/state.py 152 11 93% 86, 96, 102, 112, 150, 201, 217, 223, 273-275
------------------------------------------------------------------------------
TOTAL 3763 1150 69%
20 files skipped due to complete coverage.
Required test coverage of 60% reached. Total coverage: 69.44%
240 passed, 7 warnings in 16.77s
```
- Tests collected: 240
- Tests passed: 240
- Tests failed: 0
- Test duration: 16.77s
- Coverage: 69.44% (60% floor enforced via pytest-cov)
### Type Safety (mypy --ignore-missing-imports)
```
src/impakt/web/callbacks/channel_callbacks.py:103: error: Incompatible return value type (got "NoReturn", expected "str | None") [return-value]
src/impakt/web/callbacks/export_callbacks.py:85: error: Module "dash.dcc" does not explicitly export attribute "send_data_frame" [attr-defined]
src/impakt/web/callbacks/export_callbacks.py:85: error: Call to untyped function "send_data_frame" in typed context [no-untyped-call]
src/impakt/web/callbacks/cursor_callbacks.py:25: error: Call to untyped function "clientside_callback" in typed context [no-untyped-call]
Found 34 errors in 16 files (checked 72 source files)
```
- Total errors: 34 (unchanged from previous)
- Files with errors: 16 / 72 (78% clean)
- Top error categories:
- `[attr-defined]` 9 — attribute access on loosely typed objects (Dash stubs)
- `[no-any-return]` 6 — returning `Any` from typed functions
- `[return-value]` 4 — incompatible return type
- `[import-untyped]` 3 — third-party stubs missing
- `[assignment]` 3 — incompatible assignment
- `[var-annotated]` 2 — need type annotation
- `[valid-type]` 2 — invalid type expression
- `[no-untyped-call]` 2 — call to untyped function
- `[unused-ignore]` 1
- `[no-untyped-def]` 1
- `[comparison-overlap]` 1
### Lint (ruff)
```
All checks passed!
```
- Total violations: 0
- Auto-fixable: N/A
- Top violation rules: none
### Complexity
- File size: min=1 / median=132 / mean=147 / max=692
- Files >300 lines: 8 / 72
- High-complexity files (branch density >15):
```
80 src/impakt/io/mme.py (692 lines) -- ISO 13499 parser, justified
44 src/impakt/web/components/criteria.py (342 lines) -- UI assembly with protocol logic
32 src/impakt/web/callbacks/plot_callbacks.py (317 lines) -- transform pipeline orchestration
30 src/impakt/channel/model.py (456 lines) -- core data model, multiple classes
27 src/impakt/web/state.py (275 lines) -- app state with multi-test support
27 src/impakt/protocol/euro_ncap.py (237 lines) -- sliding-scale scoring tables
26 src/impakt/plot/engine.py (316 lines) -- Plotly rendering with corridors
21 src/impakt/web/callbacks/channel_callbacks.py (236 lines) -- selection/filter callbacks
21 src/impakt/protocol/iihs.py (179 lines) -- G/A/M/P rating logic
20 src/impakt/web/components/channel_grid.py (418 lines) -- DataTable assembly
19 src/impakt/script/cli.py (137 lines) -- CLI arg parsing
```
### Documentation
- Docstring coverage: 420 / 457 definitions (91.9%)
- Modules with `__all__`: 11 / 11 (all modules)
- channel: YES
- criteria: YES
- io: YES
- plot: YES
- plugin: YES
- protocol: YES
- report: YES
- script: YES
- template: YES
- transform: YES
- web: YES
- README: 1,266 lines with Mermaid diagrams
- Architectural diagrams: yes
### Security
```
eval/exec (all hits):
src/impakt/transform/math_expr.py:149: eval(self.expression, {"__builtins__": {}}, namespace) # noqa: S307
subprocess/os.system hits:
src/impakt/transform/math_expr.py:68: "subprocess", ← string in forbidden-token blocklist, NOT a call
```
- eval/exec (sandboxed): 1 — `math_expr.py:149`, restricted builtins `{}` + 16-token blocklist; noqa: S307
- eval/exec (unsandboxed): 0 (grep excluding # noqa returns 0)
- subprocess: 1 confirmed false positive — line 68 is a blocklist string entry, not an invocation
- Hardcoded secrets: 0
- Bare except: 0
### Maintainability
```
TODO: 0
FIXME: 0
HACK: 0
logging calls: 50
try/except blocks: 53
bare excepts: 0
internal imports: 198
```
- TODO: 0
- FIXME: 0
- HACK: 0
- Logging calls: 50
- try/except blocks: 53
- Bare excepts: 0
- Internal imports (coupling): 198
---
## Scorecard
| # | Dimension | Weight | Score | Weighted | Justification |
|---|-----------|--------|-------|----------|---------------|
| 1 | Test Health | 20% | 8.0/10 | 1.60 | 240/240 pass; test:source ratio 0.259 (within 0.20.5 band); integration tests with real datasets present; 69.44% coverage measured (below 80% ceiling for a 9+). Matches rubric row "100% pass, ratio 0.20.5, integration tests present" = 8. |
| 2 | Type Safety | 15% | 6.8/10 | 1.02 | mypy (ignore-missing-imports), 34 errors in 16 files — unchanged. Linear interpolation between 6 (<50 errors) and 8 (<10 errors): 6 + (5034)/(5010) × 2 = 6.8. |
| 3 | Lint Hygiene | 10% | 10.0/10 | 1.00 | `ruff check src/` reports 0 violations — perfect score maintained. |
| 4 | Architecture | 15% | 10.0/10 | 1.50 | Clear 4-layer design (io → transform/channel → protocol → web/plot). Plugin system present. All 11 public modules export `__all__`. No layer violations confirmed (io/transform/protocol/channel contain no imports from web or plot). |
| 5 | Documentation | 10% | 9.0/10 | 0.90 | 91.9% docstring coverage exceeds 90% threshold. Comprehensive README with Mermaid diagrams. No generated API reference (Sphinx/mkdocs), so not a full 10. |
| 6 | Complexity | 10% | 6.5/10 | 0.65 | Median 132 (<150, excellent). 8 files >300 lines (unchanged). Interpolated between score 8 (≤3 files) and 6 (≤10 files): 6 + (108)/(103) × 2 = 6.57 → 6.5. |
| 7 | Security | 10% | 8.5/10 | 0.85 | Single eval sandboxed with `{"__builtins__": {}}` + 16-item token blocklist; noqa annotation in place. Subprocess hit confirmed false positive. No secrets, no bare excepts. Interpolated between 9 (fully sandboxed) and 7 (partially sandboxed) for conservative assessment. |
| 8 | Maintainability | 10% | 8.5/10 | 0.85 | Zero debt markers. Zero bare excepts. 50 logging calls. Modern tooling (uv, hatchling, ruff, mypy). Interpolated between 10 (0 markers + all criteria) and 8 (<5 markers) at 8.5 for consistency with prior assessments; high internal coupling (198) noted. |
### Composite Score: **83.7 / 100**
### Grade: **B+**
Calculation: (8.0×0.20 + 6.8×0.15 + 10.0×0.10 + 10.0×0.15 + 9.0×0.10 + 6.5×0.10 + 8.5×0.10 + 8.5×0.10) × 10
= (1.60 + 1.02 + 1.00 + 1.50 + 0.90 + 0.65 + 0.85 + 0.85) × 10
= 8.37 × 10 = **83.7**
---
## Delta from Previous Assessment
| Dimension | Previous | Current | Change |
|-----------|----------|---------|--------|
| Test Health | 8.0 | 8.0 | 0.0 |
| Type Safety | 6.8 | 6.8 | 0.0 |
| Lint Hygiene | 10.0 | 10.0 | 0.0 |
| Architecture | 10.0 | 10.0 | 0.0 |
| Documentation | 9.0 | 9.0 | 0.0 |
| Complexity | 6.5 | 6.5 | 0.0 |
| Security | 8.5 | 8.5 | 0.0 |
| Maintainability | 8.5 | 8.5 | 0.0 |
| **Composite** | **83.7** | **83.7** | **0.0** |
---
## Top Improvements Since Last Assessment
1. **No regression** — All dimensions held steady; the 8-file >300-lines count is unchanged, coverage held at ~69.4%, and 34 mypy errors remain.
2. **Minor source growth** — +12 lines (10,563 → 10,575) with no new files or test changes; composition is stable.
3. **Coverage delta negligible** — 69.51% → 69.44% (0.07 pp); within measurement noise.
---
## Recommended Actions (Priority Order)
| # | Action | Effort | Impact | Dimensions Affected |
|---|--------|--------|--------|---------------------|
| 1 | Increase test coverage from 69.44% to ≥80%: add unit tests for uncovered branches in `web/components/transforms.py` (39% covered), `plot/engine.py`, and `io/mme.py` parser edge-cases | 24 hr | Test Health +1.0 → 9.0 (+0.20 composite) | Test Health |
| 2 | Resolve 9 `[attr-defined]` + 6 `[no-any-return]` + 4 `[return-value]` mypy errors in the web layer to reach <10 total errors; consider adding Dash type stubs or local overrides | 12 hr | Type Safety +1.2 → 8.0 (+0.18 composite) | Type Safety |
| 3 | Decompose `channel_grid.py` (418 lines) and `channel/model.py` (456 lines) into focused sub-modules; extract sub-parsers from `mme.py` if it grows beyond 800 lines | 35 hr | Complexity +0.5 → 7.0 (+0.05 composite) | Complexity |
| 4 | Set up mkdocs-material + mkdocstrings to auto-generate API reference from existing docstrings (91.9% coverage makes this low-friction) | 12 hr | Documentation +1.0 → 10.0 (+0.10 composite) | Documentation |
| 5 | Replace `eval`-based math expression evaluator in `math_expr.py` with an AST-based parser (`ast.parse` + safe node visitor) to eliminate last eval usage entirely | 23 hr | Security +0.5 → 9.0 (+0.05 composite) | Security |
**Projected composite after actions 14: ~87.0 (B+, approaching A threshold); after all 5: ~87.5 (B+)**
---
## Notes
- **All scores unchanged from QA-2026-04-11_1054.md.** The codebase added 12 source lines with no structural changes. This is a stability confirmation assessment, not a milestone improvement run.
- **Architecture qualitatively verified.** All 11 `__init__.py` files confirmed present with `__all__`. Grep for web/plot imports in io/transform/channel/protocol layers returned zero hits — no layer violations exist.
- **Security subprocess false positive persists.** `math_expr.py:68` contains `"subprocess"` as a string entry in its forbidden-token blocklist, not an invocation. The grep count of 1 is expected and benign.
- **eval sandboxing confirmed.** The single `eval` call at `math_expr.py:149` uses `{"__builtins__": {}}` (removes all builtins) plus the 16-token text blocklist scanned before evaluation. The `# noqa: S307` suppresses the ruff/bandit rule correctly.
- **Coverage measurement note.** The `--co` (collect-only) run reports 30.67% because 11 files are skipped during collection; the full run gives 69.44%. Use only the full-run number for scoring.
- **Next A-grade path.** Reaching 90+ requires: coverage ≥80% (+0.20), type errors <10 (+0.18), and generated API docs (+0.10) = minimum +0.48 composite → ~8788. Getting to 90 additionally requires complexity improvement (more decomposition) or maintainability nudge.

View File

@@ -36,7 +36,7 @@ dependencies = [
"weasyprint>=60.0", "weasyprint>=60.0",
"pydantic>=2.0", "pydantic>=2.0",
"plotly-resampler>=0.11.0", "plotly-resampler>=0.11.0",
"pytz>=2026.1.post1", "pytz>=2024.1", # required by plotly-resampler (not declared in its deps)
] ]
[project.optional-dependencies] [project.optional-dependencies]

View File

@@ -52,9 +52,7 @@ class PlotEngine:
aggregation on zoom/pan events. aggregation on zoom/pan events.
""" """
def render( def render(self, spec: PlotSpec, *, resample: bool = False) -> go.Figure | FigureResampler:
self, spec: PlotSpec, *, resample: bool = False
) -> go.Figure | FigureResampler:
"""Render a PlotSpec into an interactive Plotly figure. """Render a PlotSpec into an interactive Plotly figure.
Args: Args:
@@ -97,8 +95,19 @@ class PlotEngine:
for corridor in spec.corridors: for corridor in spec.corridors:
self._add_corridor(fig, corridor, resample=resample) self._add_corridor(fig, corridor, resample=resample)
# Determine render order: non-focus traces first, focus trace last (on top)
focus_idx = spec.focus_index
if focus_idx is not None and focus_idx < 0:
focus_idx = len(spec.channels) + focus_idx # resolve negative index
render_order = list(range(len(spec.channels)))
if focus_idx is not None and 0 <= focus_idx < len(spec.channels):
render_order.remove(focus_idx)
render_order.append(focus_idx)
# Add channel traces # Add channel traces
for i, ch_ref in enumerate(spec.channels): for i in render_order:
ch_ref = spec.channels[i]
ch = ch_ref.channel ch = ch_ref.channel
if ch is None: if ch is None:
continue continue
@@ -111,12 +120,18 @@ class PlotEngine:
color = style.color or DEFAULT_COLORS[i % len(DEFAULT_COLORS)] color = style.color or DEFAULT_COLORS[i % len(DEFAULT_COLORS)]
label = style.label or ch_ref.label label = style.label or ch_ref.label
# Focus trace gets enhanced styling
is_focus = i == focus_idx
line_width = 2.5 if is_focus else style.line_width
if is_focus and not style.color:
color = "#ffc107" # amber — focus highlight
trace_kwargs: dict[str, Any] = { trace_kwargs: dict[str, Any] = {
"mode": "lines", "mode": "lines",
"name": label, "name": label,
"line": dict( "line": dict(
color=color, color=color,
width=style.line_width, width=line_width,
dash=style.line_dash, dash=style.line_dash,
), ),
"opacity": style.opacity, "opacity": style.opacity,
@@ -128,8 +143,6 @@ class PlotEngine:
) )
if resample: if resample:
# FigureResampler: pass full-resolution data via hf_x/hf_y
# and use Scattergl for WebGL-accelerated rendering
fig.add_trace( fig.add_trace(
go.Scattergl(**trace_kwargs), go.Scattergl(**trace_kwargs),
hf_x=ch.time, hf_x=ch.time,

View File

@@ -173,3 +173,6 @@ class PlotSpec:
compact: bool = False compact: bool = False
hovermode: str | bool = "x unified" hovermode: str | bool = "x unified"
margin: dict[str, int] | None = None margin: dict[str, int] | None = None
# Focus: index into channels list to render last (on top) with
# thicker line. None means no focus. -1 means last channel.
focus_index: int | None = None

View File

@@ -38,10 +38,6 @@ logger = logging.getLogger(__name__)
# Module-level engine instance (stateless, safe to reuse) # Module-level engine instance (stateless, safe to reuse)
_engine = PlotEngine() _engine = PlotEngine()
# Server-side storage for the current FigureResampler instance.
# Single-user desktop app — no multi-tenant isolation needed.
_current_resampler: FigureResampler | None = None
def _build_transform_chain( def _build_transform_chain(
cfc_value: str, cfc_value: str,
@@ -160,19 +156,16 @@ def _build_plot_spec(
cursor_x1: float | None, cursor_x1: float | None,
cursor_x2: float | None, cursor_x2: float | None,
corridors: list[dict[str, Any]] | None = None, corridors: list[dict[str, Any]] | None = None,
focus_key: str | None = None, focus_index: int | None = None,
focus_channel: tuple[str, Channel] | None = None,
) -> PlotSpec: ) -> PlotSpec:
"""Build a PlotSpec from resolved channels and UI state. """Build a PlotSpec from resolved channels and UI state.
Channels are already transformed — they are wrapped in ChannelRef Channels are already transformed — they are wrapped in ChannelRef
objects with no additional transform chain (transforms were applied objects with no additional transform chain.
during resolution).
If ``focus_channel`` is provided, it is rendered last (on top) with ``focus_index`` is the index into ``channels`` of the focused channel.
a thicker line so it stands out from pinned channels. PlotEngine renders this trace last (on top) with enhanced styling.
""" """
# Build ChannelRef objects — pinned channels first (thinner)
refs: list[ChannelRef] = [] refs: list[ChannelRef] = []
for i, (label, ch) in enumerate(channels): for i, (label, ch) in enumerate(channels):
color = DEFAULT_COLORS[i % len(DEFAULT_COLORS)] color = DEFAULT_COLORS[i % len(DEFAULT_COLORS)]
@@ -183,20 +176,6 @@ def _build_plot_spec(
) )
) )
# Add focus channel last so it renders on top
if focus_channel is not None:
f_label, f_ch = focus_channel
refs.append(
ChannelRef(
channel=f_ch,
style=PlotStyle(
label=f_label,
color="#ffc107", # amber — matches focus highlight in grid
line_width=2.5,
),
)
)
# Build Corridor objects from raw dicts # Build Corridor objects from raw dicts
corridor_objs: list[Corridor] = [] corridor_objs: list[Corridor] = []
if corridors: if corridors:
@@ -226,13 +205,13 @@ def _build_plot_spec(
corridors=corridor_objs, corridors=corridor_objs,
x_cursors=x_cursors, x_cursors=x_cursors,
y_label=y_label, y_label=y_label,
compact=True, # Web UI always uses compact mode compact=True,
focus_index=focus_index,
) )
def register_plot_callbacks(app: dash.Dash, app_state: AppState) -> None: def register_plot_callbacks(app: dash.Dash, app_state: AppState) -> None:
"""Register all plot-related callbacks.""" """Register all plot-related callbacks."""
global _current_resampler
@app.callback( @app.callback(
Output({"type": "plot-graph", "index": 0}, "figure"), Output({"type": "plot-graph", "index": 0}, "figure"),
@@ -280,31 +259,45 @@ def register_plot_callbacks(app: dash.Dash, app_state: AppState) -> None:
show_resultant, show_resultant,
) )
# Resolve focus channel (if not already pinned) # Resolve focus: if focus_key is one of the selected channels,
focus_channel: tuple[str, Channel] | None = None # find its index. If it's not selected, add it to the channels list
if focus_key and focus_key not in (selected_keys or []): # and set focus_index to the new last position.
focus_resolved = _resolve_channels( focus_idx: int | None = None
[focus_key], if focus_key:
app_state, # Check if focus is already in the selected channels
cfc_value, for i, (_, ch) in enumerate(channels):
y_align, ch_key = f"{ch.source_test_id}::{ch.name}" if ch.source_test_id else ch.name
x_align_method or "none", if ch_key == focus_key:
x_align_value, focus_idx = i
False, # no resultant for single focus break
)
if focus_resolved: # If not in selected, resolve and append
focus_channel = focus_resolved[0] if focus_idx is None:
focus_resolved = _resolve_channels(
[focus_key],
app_state,
cfc_value,
y_align,
x_align_method or "none",
x_align_value,
False,
)
if focus_resolved:
channels.append(focus_resolved[0])
focus_idx = len(channels) - 1
spec = _build_plot_spec( spec = _build_plot_spec(
channels, cursor_x1, cursor_x2, corridors_data, channels,
focus_key=focus_key, cursor_x1,
focus_channel=focus_channel, cursor_x2,
corridors_data,
focus_index=focus_idx,
) )
fig = _engine.render(spec, resample=True) fig = _engine.render(spec, resample=True)
# Store the resampler for relayoutData callbacks # Store the resampler in AppState for relayoutData callbacks
if isinstance(fig, FigureResampler): if isinstance(fig, FigureResampler):
_current_resampler = fig app_state.current_resampler = fig
return fig return fig
@@ -319,6 +312,6 @@ def register_plot_callbacks(app: dash.Dash, app_state: AppState) -> None:
FigureResampler intercepts relayoutData events and returns a FigureResampler intercepts relayoutData events and returns a
Patch that updates only the visible trace data — no full re-render. Patch that updates only the visible trace data — no full re-render.
""" """
if _current_resampler is None or relayout_data is None: if app_state.current_resampler is None or relayout_data is None:
return no_update return no_update
return _current_resampler.construct_update_data_patch(relayout_data) return app_state.current_resampler.construct_update_data_patch(relayout_data)

View File

@@ -37,6 +37,9 @@ class AppState:
self._active_template: TemplateSpec | None = None self._active_template: TemplateSpec | None = None
# Per-channel transform overrides: {channel_key: {"cfc": "600"}} # Per-channel transform overrides: {channel_key: {"cfc": "600"}}
self.channel_overrides: dict[str, dict[str, Any]] = {} self.channel_overrides: dict[str, dict[str, Any]] = {}
# Current FigureResampler instance for zoom/pan resampling.
# Stored here (not as a module global) so it's per-AppState.
self.current_resampler: Any = None
# Active corridors # Active corridors
self.corridors: list[dict[str, Any]] = [] self.corridors: list[dict[str, Any]] = []

2
uv.lock generated
View File

@@ -567,7 +567,7 @@ requires-dist = [
{ name = "plotly", specifier = ">=5.18" }, { name = "plotly", specifier = ">=5.18" },
{ name = "plotly-resampler", specifier = ">=0.11.0" }, { name = "plotly-resampler", specifier = ">=0.11.0" },
{ name = "pydantic", specifier = ">=2.0" }, { name = "pydantic", specifier = ">=2.0" },
{ name = "pytz", specifier = ">=2026.1.post1" }, { name = "pytz", specifier = ">=2024.1" },
{ name = "pyyaml", specifier = ">=6.0" }, { name = "pyyaml", specifier = ">=6.0" },
{ name = "scipy", specifier = ">=1.10" }, { name = "scipy", specifier = ">=1.10" },
{ name = "weasyprint", specifier = ">=60.0" }, { name = "weasyprint", specifier = ">=60.0" },