Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,10 @@ jobs:
- name: Ruff format
run: uv run ruff format --check cli tests
- name: Pyright
run: uv run pyright
run: |
# Optional Java AST backend (analyzers extra); install for typecheck even if lock omits it.
uv pip install 'javalang>=0.13.0'
uv run pyright

vscode:
runs-on: ubuntu-latest
Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/secure.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,6 @@ jobs:
- name: Sync and audit
run: |
uv sync --all-extras --frozen
uv run pip-audit --strict
# Audit locked production deps only (exclude local project + examples/dev tooling noise).
uv export --frozen --no-dev --no-emit-project -o "${RUNNER_TEMP}/audit-requirements.txt"
uv run pip-audit --strict -r "${RUNNER_TEMP}/audit-requirements.txt"
20 changes: 15 additions & 5 deletions .github/workflows/tour-must-pass.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ on:
jobs:
tour:
runs-on: ubuntu-latest
env:
# fixtures/fixture_hot_path.py imports examples.app.hot_path
PYTHONPATH: ${{ github.workspace }}
steps:
- uses: actions/checkout@v4
with:
Expand All @@ -28,12 +31,19 @@ jobs:

- name: Create lens from PR files
run: |
CHANGED=$(git diff --name-only HEAD~1 2>/dev/null | tr '\n' ' ' || true)
if [ -z "$(echo "$CHANGED" | tr -d '[:space:]')" ]; then
CHANGED="examples/app/hot_path.py"
fi
u lens from-seeds --seed $CHANGED --map maps/repo.json -o maps/lens.json
mapfile -t CHANGED < <(git diff --name-only HEAD~1 2>/dev/null || true)
# Qnames use path without .py (e.g. examples/app/hot_path:run_hot_path).
SEED_ARGS=(--seed "examples/app/hot_path" --seed "run_hot_path")
for f in "${CHANGED[@]}"; do
[ -n "$f" ] || continue
case "$f" in
*.py) SEED_ARGS+=(--seed "${f%.py}") ;;
esac
done
u lens from-seeds "${SEED_ARGS[@]}" --map maps/repo.json -o maps/lens.json
test -f maps/lens.json
python -c "import json; d=json.load(open('maps/lens.json')); assert d.get('functions'), 'lens empty'"


- name: Generate gRPC Python code
continue-on-error: true
Expand Down
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,12 @@ metrics/
!schemas/*.json
!examples/**/*.json
!.bandit-baseline.json
!cli/ucli/analyzers/js_ast/package.json
!cli/ucli/analyzers/js_ast/package-lock.json
!ide/vscode/understand-first/package.json
!ide/vscode/understand-first/package-lock.json
!examples/react_dashboard/package.json
!examples/react_dashboard/package-lock.json

# IDE
.vscode/
Expand Down
16 changes: 10 additions & 6 deletions cli/ucli/analyzers/csharp_ast/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ static FileEntry ParseFile(string abs, string display)
if (member is MethodDeclarationSyntax method)
{
var simple = method.Identifier.ValueText;
if (string.IsNullOrEmpty(simple) || IsKeywordCallee(simple))
if (string.IsNullOrEmpty(simple) || AstHelpers.IsKeywordCallee(simple))
continue;
var local = $"{typeName}.{simple}";
var line = method.Identifier.GetLocation().GetLineSpan().StartLinePosition.Line + 1;
Expand Down Expand Up @@ -271,7 +271,7 @@ public override void VisitBinaryExpression(BinaryExpressionSyntax node)
public override void VisitInvocationExpression(InvocationExpressionSyntax node)
{
var name = InvocationName(node.Expression);
if (name is not null && !IsKeywordCallee(name.Contains('.') ? name.Split('.')[^1] : name))
if (name is not null && !AstHelpers.IsKeywordCallee(name.Contains('.') ? name.Split('.')[^1] : name))
Calls.Add(name);
base.VisitInvocationExpression(node);
}
Expand All @@ -292,10 +292,14 @@ when id.Identifier.ValueText is not ("this" or "base")
};
}

static bool IsKeywordCallee(string name) =>
name is "if" or "for" or "foreach" or "while" or "switch" or "case" or "catch"
or "return" or "new" or "throw" or "nameof" or "typeof" or "sizeof" or "default"
or "checked" or "unchecked" or "await" or "base" or "this";
/// <summary>Shared helpers visible to top-level local functions and visitor types.</summary>
static class AstHelpers
{
public static bool IsKeywordCallee(string name) =>
name is "if" or "for" or "foreach" or "while" or "switch" or "case" or "catch"
or "return" or "new" or "throw" or "nameof" or "typeof" or "sizeof" or "default"
or "checked" or "unchecked" or "await" or "base" or "this";
}

sealed class Request
{
Expand Down
5 changes: 4 additions & 1 deletion cli/ucli/analyzers/csharp_ast_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ def run_csharp_ast_worker(
"--project",
str(_PROJECT),
"--verbosity",
"quiet",
"minimal",
],
input=payload,
capture_output=True,
Expand All @@ -151,6 +151,9 @@ def run_csharp_ast_worker(
if proc.returncode != 0:
if csharp_analyzer_mode() == "ast":
err = (proc.stderr or proc.stdout or "").strip() or f"exit {proc.returncode}"
# Prefer the last lines — MSBuild banners are noisy; real errors trail.
if len(err) > 2000:
err = err[-2000:]
raise RuntimeError(f"C# AST worker failed: {err}")
return None

Expand Down
10 changes: 5 additions & 5 deletions cli/ucli/analyzers/java_analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -461,7 +461,7 @@ def java_analyzer_mode() -> str:

def javalang_available() -> bool:
try:
import javalang # noqa: F401
import javalang # noqa: F401 # pyright: ignore[reportMissingImports]
except ImportError:
return False
return True
Expand All @@ -471,7 +471,7 @@ def _javalang_decision_complexity(method_node: Any) -> int:
"""Structural decision-point count (not Python McCabe visitor parity)."""
if method_node is None:
return 1
import javalang.tree as jtree
import javalang.tree as jtree # pyright: ignore[reportMissingImports]

score = 1
for _path, _node in method_node.filter(jtree.IfStatement):
Expand All @@ -497,7 +497,7 @@ def _javalang_decision_complexity(method_node: Any) -> int:
def _javalang_calls(method_node: Any) -> list[str]:
if method_node is None:
return []
import javalang.tree as jtree
import javalang.tree as jtree # pyright: ignore[reportMissingImports]

calls: list[str] = []
seen: set[str] = set()
Expand All @@ -513,8 +513,8 @@ def _javalang_calls(method_node: Any) -> list[str]:
def _parse_java_javalang(src: str, file_path: pathlib.Path) -> tuple[dict[str, Any], str] | None:
"""Return (functions, package) or None if parse fails / javalang missing."""
try:
import javalang
import javalang.tree as jtree
import javalang # pyright: ignore[reportMissingImports]
import javalang.tree as jtree # pyright: ignore[reportMissingImports]
except ImportError:
return None
try:
Expand Down
30 changes: 30 additions & 0 deletions cli/ucli/analyzers/js_ast/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 13 additions & 0 deletions cli/ucli/analyzers/js_ast/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"name": "understand-first-js-ast",
"private": true,
"version": "0.3.0",
"description": "Optional Node worker for JS/TS AST maps (TypeScript compiler API).",
"type": "module",
"engines": {
"node": ">=18"
},
"dependencies": {
"typescript": "^5.8.2"
}
}
28 changes: 7 additions & 21 deletions cli/ucli/analyzers/rust_analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -283,9 +283,7 @@ def walk(prefix: list[str], fragment: str) -> None:
if not re.match(r"^[A-Za-z_][\w]*$", name):
return
if name == "self" and prefix:
bindings.append(
{"name": prefix[-1], "imported": prefix[-1], "module": prefix[:-1]}
)
bindings.append({"name": prefix[-1], "imported": prefix[-1], "module": prefix[:-1]})
return
bindings.append({"name": name, "imported": name, "module": list(prefix)})

Expand Down Expand Up @@ -526,14 +524,10 @@ def _attach_cross_module_edges(
for u in uses
)
if declared or use_mentions:
target_file = _resolve_mod_target_file(
caller_file, mod_name, by_stem
)
target_file = _resolve_mod_target_file(caller_file, mod_name, by_stem)
if target_file:
local_map = by_file_short.get(target_file, {})
candidates = list(
dict.fromkeys(local_map.get(simple, []))
)
candidates = list(dict.fromkeys(local_map.get(simple, [])))
resolved = _pick_unique(candidates, simple, caller_qn)

if resolved is None and "::" not in token:
Expand All @@ -547,17 +541,11 @@ def _attach_cross_module_edges(
module = module[1:]
if module:
mod_name = module[-1]
target_file = _resolve_mod_target_file(
caller_file, mod_name, by_stem
)
target_file = _resolve_mod_target_file(caller_file, mod_name, by_stem)
if target_file:
local_map = by_file_short.get(target_file, {})
candidates = list(
dict.fromkeys(local_map.get(imported, []))
)
resolved = _pick_unique(
candidates, imported, caller_qn
)
candidates = list(dict.fromkeys(local_map.get(imported, [])))
resolved = _pick_unique(candidates, imported, caller_qn)
elif not bindings:
# Unique among child modules declared via `mod` only.
kids = child_files_by_caller.get(caller_file) or set()
Expand Down Expand Up @@ -681,9 +669,7 @@ def _build_ast_map(
stem = str(pathlib.PurePosixPath(display_file).with_suffix(""))

mods_raw = entry.get("mods") or []
file_mods[display_file] = (
[str(m) for m in mods_raw] if isinstance(mods_raw, list) else []
)
file_mods[display_file] = [str(m) for m in mods_raw] if isinstance(mods_raw, list) else []
file_uses[display_file] = _normalize_worker_uses(entry.get("uses"))

locals_map = entry.get("functions") or {}
Expand Down
4 changes: 1 addition & 3 deletions cli/ucli/commands/scan_ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,7 @@ def _complexity_kind_label(result: dict[str, Any]) -> str:
analyzer = str(result.get("analyzer") or "")
if kind == "mccabe":
return "McCabe (Python AST)"
if kind == "ast-cyclomatic" or (
analyzer.endswith("-ast") and analyzer != "python-ast"
):
if kind == "ast-cyclomatic" or (analyzer.endswith("-ast") and analyzer != "python-ast"):
return "ast-cyclomatic (JS/TS/Go structural decision points — not Python McCabe)"
if kind == "keyword-heuristic" or "best-effort" in analyzer:
return "keyword-heuristic (JS/TS/Go/Java regex — not structural AST)"
Expand Down
8 changes: 5 additions & 3 deletions cli/ucli/commands/typer_groups.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

from __future__ import annotations

from typing import Optional

import typer

from ucli.commands.ci_ops import run_ci
Expand Down Expand Up @@ -123,8 +125,8 @@ def register_trace(app: typer.Typer) -> None:
def trace_module(
pyfile: str,
func: str,
a: str | None = None,
b: str | None = None,
a: Optional[str] = None, # noqa: UP045 # Typer evaluates hints; | needs 3.10+
b: Optional[str] = None, # noqa: UP045
o: str = typer.Option("traces/trace.json", "--output", "-o"),
):
run_trace_module(pyfile, func, a, b, o)
Expand Down Expand Up @@ -262,7 +264,7 @@ def scan(
interactive: bool = typer.Option(
False, "--interactive", "-i", help="Interactive scan with guided options"
),
lang: str | None = typer.Option(
lang: Optional[str] = typer.Option( # noqa: UP045 # Typer evaluates hints; | needs 3.10+
None,
"--lang",
help=(
Expand Down
2 changes: 1 addition & 1 deletion examples/react_dashboard/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,17 +96,17 @@

const toggleTheme = useCallback(() => {
setTheme(prev => prev === 'light' ? 'dark' : 'light');
}, []);

Check warning on line 99 in examples/react_dashboard/src/App.jsx

View workflow job for this annotation

GitHub Actions / react-example

React Hook useCallback has a missing dependency: 'setTheme'. Either include it or remove the dependency array

const login = useCallback((userData) => {
setUser(userData);
addNotification('Successfully logged in!', 'success');
}, [addNotification]);

Check warning on line 104 in examples/react_dashboard/src/App.jsx

View workflow job for this annotation

GitHub Actions / react-example

React Hook useCallback has a missing dependency: 'setUser'. Either include it or remove the dependency array

const logout = useCallback(() => {
setUser(null);
addNotification('Logged out successfully', 'info');
}, [addNotification]);

Check warning on line 109 in examples/react_dashboard/src/App.jsx

View workflow job for this annotation

GitHub Actions / react-example

React Hook useCallback has a missing dependency: 'setUser'. Either include it or remove the dependency array

const value = useMemo(() => ({
user,
Expand Down Expand Up @@ -360,7 +360,7 @@

// Main dashboard components
const Dashboard = () => {
const { user, theme, addNotification } = useApp();
const { theme, addNotification } = useApp();
const [selectedTimeRange, setSelectedTimeRange] = useState('7d');

// Mock API calls
Expand Down
15 changes: 15 additions & 0 deletions ide/vscode/understand-first/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,11 @@ exclude = [

[tool.uv]
package = true
# Keep production audit green: transitive CVE floors (click via typer, pydantic-settings via openapi-schema-validator).
override-dependencies = [
"click>=8.3.3; python_full_version >= '3.10'",
"pydantic-settings>=2.14.2; python_full_version >= '3.10'",
]

[tool.ruff]
line-length = 100
Expand Down
12 changes: 6 additions & 6 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -419,9 +419,9 @@ charset-normalizer==3.4.7 \
click==8.1.8 ; python_full_version < '3.10' \
--hash=sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2 \
--hash=sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a
click==8.3.2 ; python_full_version >= '3.10' \
--hash=sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5 \
--hash=sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d
click==8.3.3 ; python_full_version >= '3.10' \
--hash=sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2 \
--hash=sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613
click-didyoumean==0.3.1 \
--hash=sha256:4f82fdff0dbe64ef8ab2279bd6aa3f6a99c3b28c05aa09cbfc07c9d7fbb5a463 \
--hash=sha256:5c4bb6007cfea5f2fd6583a2fb6701a22a41eb98957e63d0fac41c10e7c3117c
Expand Down Expand Up @@ -2060,9 +2060,9 @@ pydantic-core==2.46.0 \
--hash=sha256:fbd01128431f355e309267283e37e23704f24558e9059d930e213a377b1be919 \
--hash=sha256:fbfb322c511a2b571eb93850221f875c1929dde3d056c7354d64fc90b49b8bc6 \
--hash=sha256:fd28d13eea0d8cf351dc1fe274b5070cc8e1cca2644381dee5f99de629e77cf3
pydantic-settings==2.13.1 ; python_full_version >= '3.10' \
--hash=sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025 \
--hash=sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237
pydantic-settings==2.14.2 ; python_full_version >= '3.10' \
--hash=sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440 \
--hash=sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f
pygments==2.20.0 \
--hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \
--hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176
Expand Down
Loading
Loading