Skip to content

Commit 373b1f6

Browse files
IronAdamantclaude
andcommitted
Release 0.8.0: variable taint tracking, shadow graph metrics, coverage depth
## Added - Variable taint tracking for JS/TS: `const MODULE = './foo'; require(MODULE)` is now resolved via taint map, producing `tainted_import` (confidence=1.0) instead of `dynamic_import` (0.3). Unknown variables remain `dynamic_import`. - `shadow_graph` in `stats`: total/call/import/dynamic/eval/tainted edge counts and `unknown_shadow_ratio` expose the hidden dependency surface. - Per-file dynamic risk fields in `risk_map`: `shadow_edge_count`, `dynamic_edge_count`, `unknown_require_count`, `hidden_risk_factor` on every file entry. - `coverage_depth` as new 6th risk component: `min(distinct_covering_tests/5, 1.0)` at weight 0.10. `test_instability` reduced from 0.10 to 0.05. - `hidden_risk_factor`: additive uplift (0-0.15) from dynamic/eval import density, computed separately from the 6-component reweighting system. - Confidence-weighted edges: `weight = proximity * sqrt(confidence)` blends low-confidence dynamic requires proportionally into impact scores. - 3 new glossary entries: "Dynamic require() detection", "Shadow graph", "Require confidence score". ## Changed - `CUSTOM_EXTRACTORS.md`: comprehensive JS/TS tree-sitter extractor with scope-aware variable tracking and `tainted_import` resolution. - `LLM_CONTRACT.md`: `tainted_import` in dynamic require table; `risk_map dynamic-risk fields` section with all new per-file fields. - `wiki-local/spec-project.md` and `CLAUDE.md`: updated risk formula with 6 components. - `_BASE_RISK_WEIGHTS` (`risk_meta.py`): updated to reflect new 6-component formula. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 6129e70 commit 373b1f6

12 files changed

Lines changed: 432 additions & 31 deletions

File tree

CHANGELOG.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,31 @@ All notable changes to Chisel are documented in this file.
55
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
66
This project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.8.0] - 2026-03-31
9+
10+
### Added
11+
12+
- **Variable taint tracking for JS/TS**: Regex-based tracking of `const/let/var X = './path'` assignments resolves `require(variable)` calls. Known variables upgrade to `tainted_import` (confidence=1.0); unknown variables remain `dynamic_import` (confidence=0.3). `test_mapper.py`: `_JS_VAR_ASSIGN_RE`, `_JS_SIMPLE_ASSIGN_RE`, updated `_extract_js_deps()`.
13+
- **`shadow_graph` in `stats`**: `tool_stats()` now returns a `shadow_graph` dict with `total_edges`, `call_edges`, `import_edges`, `dynamic_import_edges`, `eval_import_edges`, `tainted_import_edges`, and `unknown_shadow_ratio`. `storage.py`: `get_edge_type_counts()`.
14+
- **Per-file dynamic risk fields in `risk_map`**: Each entry now includes `shadow_edge_count`, `dynamic_edge_count`, `unknown_require_count` (via `new Function()` pattern scan in JS/TS files), and `hidden_risk_factor`. `impact.py`: updated `compute_risk_score()` and `get_risk_map()`.
15+
- **`coverage_depth` in risk formula**: New 6th component — `min(distinct_covering_tests/5, 1.0)` — with weight 0.10. `test_instability` weight reduced from 0.10 to 0.05. Risk formula: `0.35*churn + 0.25*coupling + 0.15*coverage_gap + 0.10*coverage_depth + 0.10*author_concentration + 0.05*test_instability + hidden_risk_factor`.
16+
- **`hidden_risk_factor`**: Additive uplift (0–0.15) from dynamic/eval import edge density: `min(dynamic_edge_count/20, 1.0) * 0.15`. Computed separately from the 6-component reweighting system.
17+
- **Confidence-weighted edges**: Edge weights now blend `proximity * sqrt(confidence)` so low-confidence dynamic requires contribute proportionally less to impact scores. `test_mapper.py`: `build_test_edges()`.
18+
- **`unknown_require_count`**: Count of `new Function(` patterns in JS/TS source files, indicating potential `eval`-based module loading. Surface-level heuristic for risk assessment.
19+
- **3 new glossary entries**: "Dynamic require() detection", "Shadow graph", "Require confidence score" (`wiki-local/glossary.md`).
20+
21+
### Changed
22+
23+
- **`_BASE_RISK_WEIGHTS`** (`risk_meta.py`): Updated to 6-component weights reflecting new formula.
24+
- **`docs/CUSTOM_EXTRACTORS.md`**: Completely rewritten with comprehensive JS/TS tree-sitter extractor showing scope-aware variable tracking and `tainted_import` resolution.
25+
- **`docs/LLM_CONTRACT.md`**: Dynamic require table now includes `tainted_import`; added `risk_map dynamic-risk fields` section documenting `hidden_risk_factor`, `shadow_edge_count`, `dynamic_edge_count`.
26+
- **`wiki-local/spec-project.md`**: Updated risk formula, test edge weighting section now mentions variable taint tracking and shadow graph.
27+
- **`CLAUDE.md`**: Updated risk formula bullet with correct weights, `coverage_depth`, and `hidden_risk_factor`.
28+
29+
### Fixed
30+
31+
- **`risk_map` reweighting**: Now correctly handles 6 components (was 5) when 3+ are uniform across files.
32+
833
## [0.6.5] - 2026-03-27
934

1035
### Added

CLAUDE.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ chisel/
3838
- **Zero deps**: stdlib only. `ast` for Python, regex for JS/TS/Go/Rust. `subprocess.run(["git", ...])` for git. Requires Python >= 3.11.
3939
- **FK enforcement disabled** in SQLite: stale test detection relies on orphaned edge refs; re-analysis deletes/recreates code_units freely.
4040
- **Churn formula**: `sum(1 / (1 + days_since_commit))` — recent changes weigh heavily.
41-
- **Risk formula**: `0.35*churn + 0.25*coupling + 0.2*coverage_gap + 0.1*author_concentration + 0.1*test_instability`. Coupling uses `max(git co-change, static import-graph)` breadth. `coverage_gap` is graduated (quantized to 0.25 steps: 0.0/0.25/0.5/0.75/1.0). `get_risk_map` may reweight the composite when 3+ components are uniform across files. `proximity_adjustment` optionally reduces `coverage_gap` by import distance to tested code.
41+
- **Risk formula**: `0.35*churn + 0.25*coupling + 0.15*coverage_gap + 0.10*coverage_depth + 0.10*author_concentration + 0.05*test_instability + hidden_risk_factor`. The first 6 components are reweighted when 3+ are uniform. `hidden_risk_factor` (0–0.15) is added separately from dynamic/eval import edge density. Coupling uses `max(git co-change, static import-graph)` breadth. `coverage_gap` is graduated (quantized to 0.25 steps: 0.0/0.25/0.5/0.75/1.0). `coverage_depth = min(distinct_covering_tests/5, 1.0)`. `get_risk_map` may reweight the composite when 3+ components are uniform across files. `proximity_adjustment` optionally reduces `coverage_gap` by import distance to tested code.
4242
- **Co-change ingest**: `compute_co_changes` uses adaptive `min_count` from `coupling_threshold()`; queries use `meta.co_change_query_min` so stored pairs are visible. Branch-only pairs stored in `branch_co_changes` from `merge-base..HEAD`. Commits touching >50 files are skipped (bulk operations).
4343
- **Blame caching**: Cached by file content hash, invalidated on change.
4444
- **Incremental updates**: File content hashes tracked in `file_hashes` table.
@@ -48,9 +48,10 @@ chisel/
4848
- **Ownership vs Reviewers**: `ownership` = blame-based (`role: "original_author"`). `who_reviews` = commit-activity-based (`role: "suggested_reviewer"`). Both are **git-derived signals** for agents (lineage, hot spots); they are not substitutes for team assignment in a solo workflow.
4949
- **Shared constants**: `_SKIP_DIRS` and `_EXTENSION_MAP` live in `ast_utils.py`. `_CODE_EXTENSIONS` in `engine.py` is derived from `_EXTENSION_MAP`. `_SKIP_DIRS` includes `coverage`, `.next`, `.nuxt` to exclude build/test output artifacts.
5050
- **Shared dispatch**: `dispatch_tool()` in `mcp_server.py` is used by both HTTP and stdio servers. Tool schemas and dispatch tables live in `schemas.py`.
51-
- **Edge weighting**: Test edges carry a weight (0.4-1.0) based on file proximity. `_compute_proximity_weight()` in `test_mapper.py`.
51+
- **Edge weighting**: Test edges carry a weight (0.4-1.0) based on file proximity, blended with `sqrt(confidence)` for dynamic requires: `weight = proximity * sqrt(confidence)`. `_compute_proximity_weight()` in `test_mapper.py`.
5252
- **Three-tier edge matching** in `build_test_edges()`: (1) Python import-path matching (`from myapp.utils import foo``myapp/utils.py:foo`, requires both path and name match), (2) JS/TS path-based matching (`require('../../src/services/searchService')` → resolves relative path, matches ALL code units in the resolved file), (3) name-only matching (universal fallback). Priority chain ensures precise matching where possible with file-level fallback for JS.
5353
- **JS/TS import binding extraction**: `_extract_js_deps()` extracts binding names from `const X = require('...')` (`_JS_CJS_DEFAULT_RE`), destructured requires `const { X, Y } = require('...')` (`_JS_CJS_DESTRUCTURED_RE`), and ESM defaults `import X from '...'` (`_JS_ESM_DEFAULT_RE`). All include `module_path` for path-based matching. Combined with `_JS_IMPORT_RE` (file-stem name) and `_JS_NAMED_IMPORT_RE` (ESM named imports), this covers CommonJS and ESM patterns.
54+
- **Dynamic require() detection (DynamicRequireChainTracer)**: Chisel detects `require()` patterns invisible to naive static analysis: variable refs (`require(variable)`), template literals, string concatenation, conditionals, and eval-based loading. Variable taint tracking (`const MODULE = './foo'; require(MODULE)`) resolves known variables and upgrades them to `tainted_import` (confidence=1.0). Unknown variables produce `dynamic_import` (confidence=0.3). Confidence is blended into edge weights via `proximity * sqrt(confidence)`. Files with `dynamic_import`/`eval_import` edges accumulate `hidden_risk_factor` in risk scoring: `min(dynamic_edge_count/20, 1.0) * 0.15` added to the 5-component risk formula. `shadow_edge_count` and `dynamic_edge_count` are exposed in `risk_map` output.
5455
- **JS path resolution**: `_resolve_js_module_path(test_file, module_path)` resolves relative imports against the test file's directory. `_matches_js_import_path(code_file, resolved)` strips JS/TS extensions and handles `index.js` barrel imports. `_strip_js_ext()` shared helper. `_JS_EXTENSIONS` frozenset in `test_mapper.py`.
5556
- **AST regex improvements**: C#/Java support nested generics `<A<B>>` and annotations/attributes `@Override`/`[Test]`. Kotlin supports extension functions `fun String.foo()`. C++ supports template functions and destructors `~Foo()`. Swift supports `@objc`-style attributes. Dart supports factory constructors and getters/setters.
5657
- **Jest/Mocha/Vitest test block extraction**: `_JS_JEST_BLOCK_RE` in `ast_utils.py` matches `describe('name', ...)`, `it('name', ...)`, `test('name', ...)` (plus `.only`/`.skip`/`.todo` modifiers) as code units with `unit_type` "test_suite" or "test_case". `_TEST_UNIT_TYPES` in `test_mapper.py` ensures these are recognized as test units regardless of `_is_test_name()`. This enables test edge building for JS/TS projects — the `require()`/`import` dep extraction already worked but was unreachable without test units.

chisel/engine.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -725,6 +725,28 @@ def tool_stats(self):
725725
stats["branch_coupling_commits"] = int(bc)
726726
except ValueError:
727727
pass
728+
# Shadow graph summary: edge type breakdown for dynamic require visibility
729+
edge_counts = self.storage.get_edge_type_counts()
730+
if edge_counts:
731+
stats["shadow_graph"] = {
732+
"total_edges": sum(edge_counts.values()),
733+
"call_edges": edge_counts.get("call", 0),
734+
"import_edges": edge_counts.get("import", 0),
735+
"dynamic_import_edges": (
736+
edge_counts.get("dynamic_import", 0)
737+
+ edge_counts.get("eval_import", 0)
738+
),
739+
"eval_import_edges": edge_counts.get("eval_import", 0),
740+
"tainted_import_edges": edge_counts.get("tainted_import", 0),
741+
"unknown_shadow_ratio": round(
742+
(
743+
edge_counts.get("dynamic_import", 0)
744+
+ edge_counts.get("eval_import", 0)
745+
)
746+
/ max(sum(edge_counts.values()), 1),
747+
4,
748+
),
749+
}
728750
return stats
729751

730752
# ------------------------------------------------------------------ #

chisel/impact.py

Lines changed: 62 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
"""Impact analysis, risk scoring, stale test detection, and reviewer suggestions."""
22

3+
import os
34
import re
45
from collections import defaultdict, deque
56
from datetime import datetime, timezone
67

78
from chisel.metrics import _parse_iso_date, compute_ownership
89
from chisel.static_test_imports import StaticImportIndex
910

11+
# Regex to detect eval/new Function patterns in source files (eval_import dep source)
12+
_JS_EVAL_RE = re.compile(r"new\s+Function\s*\(")
13+
1014
# Co-change coupling: breadth of partners (normalized by this count).
1115
_COCHANGE_COUPLING_CAP = 10
1216
# Static import-graph coupling: distinct neighbor files (either direction).
@@ -326,8 +330,12 @@ def compute_risk_score(self, file_path, unit_name=None, failure_rates=None,
326330
coverage_mode="unit"):
327331
"""Compute a risk score for a file or function.
328332
329-
Formula: 0.35*churn + 0.25*coupling + 0.2*coverage_gap
330-
+ 0.1*author_concentration + 0.1*test_instability
333+
Formula: 0.35*churn + 0.25*coupling + 0.15*coverage_gap
334+
+ 0.10*coverage_depth + 0.10*author_concentration
335+
+ 0.05*test_instability + hidden_risk_factor
336+
where coverage_depth = min(distinct_covering_tests/5, 1.0)
337+
and hidden_risk_factor = min(dynamic_edge_count/20, 1.0) * 0.15
338+
from dynamic_import/eval_import edge counts.
331339
332340
Args:
333341
failure_rates: Optional pre-fetched dict of {test_id: rate}.
@@ -383,7 +391,7 @@ def compute_risk_score(self, file_path, unit_name=None, failure_rates=None,
383391
tested_lines = 0
384392
total_lines = 0
385393
covering_test_ids = set()
386-
edge_type_counts = {"call": 0, "import": 0}
394+
edge_type_counts = {"call": 0, "import": 0, "dynamic_import": 0, "eval_import": 0, "tainted_import": 0}
387395
for cu in code_units:
388396
unit_lines = cu["line_end"] - cu["line_start"] + 1
389397
total_lines += unit_lines
@@ -422,17 +430,30 @@ def compute_risk_score(self, file_path, unit_name=None, failure_rates=None,
422430
covering_test_ids, failure_rates, duration_cv,
423431
)
424432

433+
# Hidden risk from dynamic/eval imports (shadow graph)
434+
dynamic_edge_count = (
435+
edge_type_counts.get("dynamic_import", 0)
436+
+ edge_type_counts.get("eval_import", 0)
437+
)
438+
shadow_edge_count = total_edges - edge_type_counts.get("call", 0)
439+
hidden_risk_factor = min(dynamic_edge_count / 20.0, 1.0) * 0.15
425440
risk = (
426441
0.35 * churn_norm
427442
+ 0.25 * coupling_norm
428-
+ 0.2 * coverage_gap
429-
+ 0.1 * author_conc
430-
+ 0.1 * instability
443+
+ 0.15 * coverage_gap
444+
+ 0.10 * coverage_depth
445+
+ 0.10 * author_conc
446+
+ 0.05 * instability
447+
+ hidden_risk_factor
431448
)
432449
return {
433450
"file_path": file_path,
434451
"unit_name": unit_name,
435452
"risk_score": round(risk, 4),
453+
"shadow_edge_count": shadow_edge_count,
454+
"dynamic_edge_count": dynamic_edge_count,
455+
"unknown_require_count": edge_type_counts.get("eval_import", 0),
456+
"hidden_risk_factor": round(hidden_risk_factor, 4),
436457
"breakdown": {
437458
"churn": round(churn_norm, 4),
438459
"coupling": round(coupling_norm, 4),
@@ -446,6 +467,7 @@ def compute_risk_score(self, file_path, unit_name=None, failure_rates=None,
446467
"edge_type_quality": edge_type_quality,
447468
"author_concentration": round(author_conc, 4),
448469
"test_instability": round(instability, 4),
470+
"hidden_risk": round(hidden_risk_factor, 4),
449471
},
450472
}
451473

@@ -750,7 +772,7 @@ def get_risk_map(self, directory=None, exclude_tests=True,
750772
tested_lines = 0
751773
total_lines = 0
752774
covering_test_ids = set()
753-
edge_type_counts = {"call": 0, "import": 0}
775+
edge_type_counts = {"call": 0, "import": 0, "dynamic_import": 0, "eval_import": 0, "tainted_import": 0}
754776
for cu in code_units:
755777
unit_lines = cu["line_end"] - cu["line_start"] + 1
756778
total_lines += unit_lines
@@ -787,17 +809,46 @@ def get_risk_map(self, directory=None, exclude_tests=True,
787809
covering_test_ids, failure_rates, duration_cv_by_test,
788810
)
789811

812+
# Hidden risk from dynamic/eval imports (shadow graph)
813+
# Files with many dynamic_import/eval_import edges have unknown deps
814+
dynamic_edge_count = (
815+
edge_type_counts.get("dynamic_import", 0)
816+
+ edge_type_counts.get("eval_import", 0)
817+
)
818+
shadow_edge_count = total_edges - edge_type_counts.get("call", 0)
819+
hidden_risk_factor = min(dynamic_edge_count / 20.0, 1.0) * 0.15
820+
821+
# unknown_require_count: eval/new Function patterns in source file.
822+
# Only applies to JS/TS files where eval patterns are relevant.
823+
# These deps produce zero edges (confidence=0) and are invisible to
824+
# impact analysis — count them directly from source to surface hidden risk.
825+
eval_pattern_count = 0
826+
if fp.endswith((".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs")):
827+
try:
828+
abs_path = os.path.join(self.project_dir, fp)
829+
with open(abs_path, encoding="utf-8", errors="replace") as fh:
830+
content = fh.read()
831+
eval_pattern_count = len(_JS_EVAL_RE.findall(content))
832+
except OSError:
833+
pass
790834
risk = (
791835
0.35 * churn_norm
792836
+ 0.25 * coupling_norm
793-
+ 0.2 * coverage_gap
794-
+ 0.1 * author_conc
795-
+ 0.1 * instability
837+
+ 0.15 * coverage_gap
838+
+ 0.10 * coverage_depth
839+
+ 0.10 * author_conc
840+
+ 0.05 * instability
841+
+ hidden_risk_factor
796842
)
843+
797844
risk_map.append({
798845
"file_path": fp,
799846
"unit_name": None,
800847
"risk_score": round(risk, 4),
848+
"shadow_edge_count": shadow_edge_count,
849+
"dynamic_edge_count": dynamic_edge_count,
850+
"unknown_require_count": eval_pattern_count,
851+
"hidden_risk_factor": round(hidden_risk_factor, 4),
801852
"coupling_partners": coupling_partners,
802853
"import_partners": import_partners,
803854
"breakdown": {
@@ -813,6 +864,7 @@ def get_risk_map(self, directory=None, exclude_tests=True,
813864
"edge_type_quality": edge_type_quality,
814865
"author_concentration": round(author_conc, 4),
815866
"test_instability": round(instability, 4),
867+
"hidden_risk": round(hidden_risk_factor, 4),
816868
},
817869
})
818870

chisel/risk_meta.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@
55
_BASE_RISK_WEIGHTS = {
66
"churn": 0.35,
77
"coupling": 0.25,
8-
"coverage_gap": 0.2,
9-
"author_concentration": 0.1,
10-
"test_instability": 0.1,
8+
"coverage_gap": 0.15,
9+
"coverage_depth": 0.10,
10+
"author_concentration": 0.10,
11+
"test_instability": 0.05,
1112
}
1213

1314
_COMPONENTS = tuple(_BASE_RISK_WEIGHTS.keys())

chisel/storage.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -604,6 +604,20 @@ def get_stale_test_edges(self):
604604
WHERE cu.id IS NULL""",
605605
)
606606

607+
def get_edge_type_counts(self):
608+
"""Count test edges grouped by edge_type.
609+
610+
Returns:
611+
Dict mapping edge_type string to count.
612+
Keys include: call, import, dynamic_import, eval_import, tainted_import.
613+
"""
614+
rows = self._fetchall(
615+
"""SELECT edge_type, COUNT(*) AS cnt
616+
FROM test_edges
617+
GROUP BY edge_type""",
618+
)
619+
return {r["edge_type"]: r["cnt"] for r in rows}
620+
607621
def get_direct_impacted_tests(self, file_path, changed_functions=None):
608622
"""Find tests with edges to code units in a file, via a single JOIN."""
609623
base_sql = """SELECT tu.id AS test_id, tu.file_path,

0 commit comments

Comments
 (0)