diff --git a/benchmarks/results/python-det-v2.json b/benchmarks/results/python-det-v2.json index f06821f..7a7817c 100644 --- a/benchmarks/results/python-det-v2.json +++ b/benchmarks/results/python-det-v2.json @@ -5,25 +5,25 @@ "bucket": "large(>10k)", "fidelity_missing": [], "file": "skills/kern/scripts/kern_cache.py", - "source_tokens": 15181, + "source_tokens": 15467, "tiers": { "L1": { "fidelity_missing": [], "fidelity_ok": true, - "ratio": 3.0, - "tokens": 5061 + "ratio": 3.01, + "tokens": 5139 }, "L2": { "fidelity_missing": [], "fidelity_ok": true, "ratio": 2.38, - "tokens": 6370 + "tokens": 6494 }, "L3": { "fidelity_missing": [], "fidelity_ok": true, "ratio": 1.18, - "tokens": 12867 + "tokens": 13066 } } } diff --git a/docs/deterministic-compiler.md b/docs/deterministic-compiler.md index 6782560..b52a5de 100644 --- a/docs/deterministic-compiler.md +++ b/docs/deterministic-compiler.md @@ -372,3 +372,141 @@ the generated benchmark record instead of assuming a fixed savings percentage. size; publish per-language results under `benchmarks/results/`. 5. **Fallback:** corpus runs with the pinned tree-sitter set absent and with syntax-broken files must degrade to the labeled generic baseline, never crash. + +--- + +## React frontend (tsx/jsx) + +Step 1's grammar table lists `tree-sitter` for TypeScript and JavaScript, but two +gaps sat under that row: `.tsx` files were routed to the plain TypeScript grammar +(no JSX support, so JSX-bearing `.tsx` degraded to a parse error), and `.jsx` +parsed fine but rendered every component as a truncated `FN` — hooks, state, +effects, events, and render structure were invisible. `kern_react.py` closes both +gaps as a post-pass over the same tree-sitter tree: no new parser, no new format. +`COMPONENT` is a symbol kind inside the existing `ModuleIR`/`Symbol`/`emit_il` +pipeline, so spans, slice hashes, tiers, faults, verify, cache, and redaction are +inherited verbatim. + +### Grammar routing + +| suffix | grammar | +| --- | --- | +| `.js` `.jsx` `.mjs` `.cjs` | `tree_sitter_javascript` (JSX built in) | +| `.ts` | `tree_sitter_typescript.language_typescript()` | +| `.tsx` | `tree_sitter_typescript.language_tsx()` | + +`parse_tsjs(text, typescript: bool = False, tsx: bool = False)` selects the +grammar (`tsx=True` wins over `typescript=True`), and `tsjs_available()` takes +the same flags for per-grammar capability probes. Both call sites +(`kern_cache.py` compile and verify paths) route this way. The React adapter +runs only on the JSX-capable grammars (JavaScript and TSX; plain TypeScript has +no JSX productions). The `generator=` header line reports +`lang=typescript frontend=tree-sitter+react` for a `.tsx` module when the +adapter fires, and plain `frontend=tree-sitter` otherwise — a strict no-op on +non-component code, verified by `TestNoOpOnPlainCode` in `tests/test_react.py`. + +A symbol is upgraded to `kind="component"` when it is a function declaration, +function expression, or arrow function, its name matches `^[A-Z]`, and a +`return` (or arrow expression body) contains a `jsx_element`, +`jsx_self_closing_element`, or `jsx_fragment`. `memo(Fn)` / `forwardRef(Fn)` +wrappers are unwrapped: the inner function is lowered, the wrapper noted. +Capitalized functions with no JSX stay plain `FN`; lowercase functions +returning JSX also stay `FN` (not components by React convention). + +### Extraction vocabulary + +```text +COMPONENT UserCard L3-18 #a3f9c2d1 + PROPS user, onClose?=noop + STATE open=false + STATE [state, dispatch]=useReducer(reducer, init) + CTX theme=useContext(ThemeContext) + REF inputRef + HOOK data=useUserData(id) + EFFECT deps=[user.id] + EVENT Card.onClick -> set open=true + RENDER + Card + Avatar src=user.avatar + span {user.name} + IF open > UserDetails user=user + FOR item in items > Row key=item.id +``` + +Rules: + +- **PROPS** — first parameter. Destructured pattern lists names with defaults + (`onClose?=noop` when a default exists; `?` when the TS type marks it optional + and that is syntactically visible). Non-destructured param renders as its name + (`props`). +- **STATE** — `const [x, setX] = useState(init)` → `STATE x=init`; setter name + recorded internally for EVENT lowering. `useReducer` renders the pair and + arguments. +- **CTX / REF / HOOK** — `useContext`, `useRef`, and custom `use[A-Z]\w*` calls + respectively. Custom hooks are opaque: call text only, no cross-file + resolution. +- **EFFECT** — `useEffect` / `useLayoutEffect`. Dependency array rendered + verbatim (`deps=[user.id]`, `deps=[]`); a missing array renders + `deps=EVERY-RENDER`. Effect body: L2 normally shows the head only but retains + risk-bearing operations required by the fault contract; L3 summarizes via + existing `flow()`. +- **EVENT** — JSX attribute matching `on[A-Z]\w+={expr}`. If the handler body is + a single known-setter call, lower to `set =`; otherwise render the + callee name or `flow()`-style summary at L3. +- **RENDER** — JSX tree, indentation = nesting: + - `{cond && }` → `IF cond > X`; ternary → `IF cond > X ELSE > Y` + - `.map(` callback returning JSX → `FOR param in receiver > X` + - Host elements (lowercase) are structure; text/expression children render as + `{expr}` capped by existing `ntext` (secret redaction inherited) + - Capitalized JSX names are component dependencies; names that match imports + cross-link naturally through the existing import lines +- Non-hook, non-render statements in the component body flow through the + existing L3 flow-op rendering unchanged (hook calls are skipped there — + they already surface as STATE/CTX/REF/HOOK/EFFECT heads). Components also + emit the same `EFFECTS` provenance line as plain functions (effect classes + plus `unknown-calls=N`) at L2 and L3. L2 retains only risk-bearing body ops + so their inline markers and `FAULT-BEFORE` entries cannot disappear. +- All line math is `\n`-only, matching the repo rule (never `str.splitlines()`). + +### Tier mapping + +| Tier | Component detail | +| --- | --- | +| L1 | `COMPONENT name (props) span #hash` — one line, like current FN heads | +| L2 | + STATE/CTX/REF/HOOK/EFFECT/EVENT heads, risk-bearing body ops, and the `EFFECTS` provenance line; RENDER collapsed to components-only tree (host elements and attributes dropped; IF/FOR structure kept) | +| L3 | Full render tree with attributes, effect bodies, handler bodies, and non-hook body statements via `flow()` | + +### Faulting + +Ambiguity never disappears silently. Seven markers reuse the existing +`!FAULT(...)` inline channel and FAULTS footer: + +| Construct | Marker | +| --- | --- | +| Hook called via alias or namespace (`R.useState`, renamed import) | `!FAULT(aliased-hook)` | +| Dynamic component (`` where Tag is a lowercase variable or member expression) | `!FAULT(dynamic-component)` | +| Spread props as the sole prop source (`{...rest}`) | rendered `...rest` + `!FAULT(spread-props)` | +| Render prop / children-as-function | `!FAULT(render-prop)`, body summarized as flow | +| Hook call inside conditional | `!FAULT(conditional-hook)` | +| Multiple or nested JSX return paths collapsed to one render root | `!FAULT(render-control-flow)` | +| Render tree exceeding op budget | explicit `…+N` + `!FAULT(render-truncated)` | + +Frontend IR remains a reasoning representation, not the write authority: edits +still require faulting exact current source and verifying the slice hash, per +the existing contract (Step 5). + +### Corpus run + +Compiled at L2 over two real corpora — KERN's own `app/` directory (4 files: +`layout.jsx`, `page.jsx`, `docs/layout.jsx`, `docs/page.jsx`) and a fresh +shallow clone of [`vercel/commerce`](https://github.com/vercel/commerce) (65 +`.js`/`.jsx`/`.ts`/`.tsx` files, `node_modules` excluded): + +```text +files=69 crashes=0 components=61 faults=8 +ratio min=1.9 median=3.3 max=10.1 +``` + +Zero crashes, components detected on both corpora, ambiguous constructs (spread +props, dynamic components, render-prop children) surfaced as faults rather than +silently dropped — the done bar this spec set for Stage 1 MVP. diff --git a/docs/superpowers/specs/2026-07-11-react-frontend-ir-design.md b/docs/superpowers/specs/2026-07-11-react-frontend-ir-design.md new file mode 100644 index 0000000..d17eb28 --- /dev/null +++ b/docs/superpowers/specs/2026-07-11-react-frontend-ir-design.md @@ -0,0 +1,127 @@ +# React Frontend IR — Design + +**Date:** 2026-07-11 +**Scope:** TSX/JSX grammar routing fix + React semantic extraction (Stage 1 MVP). Next.js adapter, type enrichment, and other frameworks are later specs. + +> **Historical note:** this spec predates the compiler-hardening merge from main. The shipped implementation adopts main's `parse_tsjs(text, typescript=, tsx=)` boolean API instead of the `dialect` parameter described below; the semantic vocabulary, tiers, and faulting are as specified. Current behavior: `docs/deterministic-compiler.md`. + +## Problem + +KERN-IL/0.2 lowers TS/JS through Tree-sitter, but: + +1. `.tsx` files are routed to the plain TypeScript grammar (`kern_cache.py` passes `typescript=True`; `parse_tsjs` loads `language_typescript()`). JSX is a syntax error in that grammar, so JSX-bearing `.tsx` files degrade to a parse-error IR. `tree_sitter_typescript.language_tsx()` exists and is unused. +2. `.jsx` parses (the JavaScript grammar includes JSX) but the extractor has no JSX semantics: components render as `FN` symbols whose return statements are truncated JSX text. Hooks, state, effects, events, and render structure — the behavioral core of a React file — are invisible. + +React source is a verbose encoding of compact behavior. A deterministic semantic lowering recovers that behavior at a fraction of the tokens. + +## Decision summary + +- **No new parser.** Tree-sitter JS and TSX grammars provide syntax; KERN supplies React meaning. Writing or committing a custom React grammar is explicitly out of scope — React is semantics on top of JS/TSX syntax, not a new syntax. +- **Extend KERN-IL, not a new format.** `COMPONENT` becomes a new symbol kind inside the existing `ModuleIR`/`Symbol`/`emit_il` pipeline. Spans, slice hashes, tiers, faults, verify, cache, and redaction are inherited verbatim. The runtime model learns one format. +- **Adapter seam, not query packs.** React lowering lives in a new module (`skills/kern/scripts/kern_react.py`) as a post-pass over the same Tree-sitter tree, in the codebase's existing manual-visitor style. Future frameworks (Next.js, Vue, Svelte) become sibling adapters emitting the same vocabulary. Declarative `.scm` query packs are deferred until a second framework proves the need. +- **Tiers stay the compression lever.** Component detail maps onto existing L1/L2/L3; no new knobs. + +## Architecture + +```text +TSX/JSX source + ↓ +parse_tsjs(text, dialect) # dialect: "js" | "ts" | "tsx" + ↓ tree-sitter CST + base symbols (FN/const/class/import) +kern_react.py post-pass # same tree, upgrades qualifying FN → COMPONENT + ↓ +ModuleIR → emit_il (existing) # tier rendering, faults, hashes +``` + +### Grammar routing + +| suffix | grammar | +| --- | --- | +| `.js` `.jsx` `.mjs` `.cjs` | `tree_sitter_javascript` (JSX built in) | +| `.ts` | `tree_sitter_typescript.language_typescript()` | +| `.tsx` | `tree_sitter_typescript.language_tsx()` | + +`parse_tsjs(text, typescript: bool)` becomes `parse_tsjs(text, dialect: str)`. Both call sites (`kern_cache.py` compile and verify paths) updated. `generator=` header line reports `lang=tsx frontend=tree-sitter+react` when the adapter fires. + +### Component detection (deterministic) + +A symbol is upgraded to `kind="component"` when: + +- it is a function declaration, function expression, or arrow function, AND +- its name matches `^[A-Z]`, AND +- a `return` (or arrow expression body) contains `jsx_element`, `jsx_self_closing_element`, or `jsx_fragment`. + +Also detected: `memo(Fn)`, `forwardRef(Fn)` wrappers — the inner function is lowered, the wrapper noted. Capitalized functions with no JSX stay plain `FN`. Lowercase functions returning JSX stay `FN` (not components by React convention). + +## Extraction vocabulary + +```text +COMPONENT UserCard L3-18 #a3f9c2d1 + PROPS user, onClose?=noop + STATE open=false + STATE [state, dispatch]=useReducer(reducer, init) + CTX theme=useContext(ThemeContext) + REF inputRef + HOOK data=useUserData(id) + EFFECT deps=[user.id] + EVENT Card.onClick -> set open=true + RENDER + Card + Avatar src=user.avatar + span {user.name} + IF open > UserDetails user=user + FOR item in items > Row key=item.id +``` + +Rules: + +- **PROPS** — first parameter. Destructured pattern lists names with defaults (`onClose?=noop` when a default exists; `?` when the TS type marks it optional and that is syntactically visible). Non-destructured param renders as its name (`props`). +- **STATE** — `const [x, setX] = useState(init)` → `STATE x=init`; setter name recorded internally for EVENT lowering. `useReducer` renders the pair and arguments. +- **CTX / REF / HOOK** — `useContext`, `useRef`, and custom `use[A-Z]\w*` calls respectively. Custom hooks are opaque: call text only, no cross-file resolution. +- **EFFECT** — `useEffect` / `useLayoutEffect`. Dependency array rendered verbatim (`deps=[user.id]`, `deps=[]`); a missing array renders `deps=EVERY-RENDER`. Effect body: L2 shows the head plus any risk-bearing ops required by the fault contract; L3 summarizes via existing `flow()`. +- **EVENT** — JSX attribute matching `on[A-Z]\w+={expr}`. If the handler body is a single known-setter call, lower to `set =`; otherwise render the callee name or `flow()`-style summary at L3. +- **RENDER** — JSX tree, indentation = nesting: + - `{cond && }` → `IF cond > X`; ternary → `IF cond > X ELSE > Y` + - `.map(` callback returning JSX → `FOR param in receiver > X` + - Host elements (lowercase) are structure; text/expression children render as `{expr}` capped by existing `ntext` (secret redaction inherited) + - Capitalized JSX names are component dependencies; names that match imports cross-link naturally through the existing import lines +- Non-hook, non-render statements in the component body flow through the existing L3 flow-op rendering unchanged; L2 retains only risk-bearing ops. +- All line math is `\n`-only, matching the repo rule (never `str.splitlines()`). + +## Tier mapping + +| Tier | Component detail | +| --- | --- | +| L1 | `COMPONENT name (props) span #hash` — one line, like current FN heads | +| L2 | + STATE/CTX/REF/HOOK/EFFECT/EVENT heads and risk-bearing body ops; RENDER collapsed to components-only tree (host elements and attributes dropped; IF/FOR structure kept) | +| L3 | Full render tree with attributes, effect bodies and handler bodies via `flow()` | + +## Faulting + +Ambiguity never disappears silently. Reuses the existing `!FAULT(...)` inline channel and FAULTS footer: + +| Construct | Marker | +| --- | --- | +| Hook called via alias or namespace (`R.useState`, renamed import) | `!FAULT(aliased-hook)` | +| Dynamic component (`` where Tag is a lowercase variable or member expression) | `!FAULT(dynamic-component)` | +| Spread props as the sole prop source (`{...rest}`) | rendered `...rest` + `!FAULT(spread-props)` | +| Render prop / children-as-function | `!FAULT(render-prop)`, body summarized as flow | +| Hook call inside conditional | `!FAULT(conditional-hook)` | +| Multiple or nested JSX return paths collapsed to one render root | `!FAULT(render-control-flow)` | +| Render tree exceeding op budget | explicit `…+N` + `!FAULT(render-truncated)` | + +Frontend IR remains a reasoning representation, not the write authority: edits still require faulting exact current source and verifying the slice hash, per the existing contract. + +## Testing + +- **`tests/test_react.py`** — golden fixtures per construct: useState, useReducer, useContext, useRef, custom hook, effect with/without deps, events (setter and non-setter), IF/FOR render lowering, fragments, memo/forwardRef, spread props, dynamic component, aliased hook, conditional hook, `.jsx` and `.tsx` dialects, TSX generics ambiguity (`` arrow). +- **Regression** — existing `test_tsjs.py` fixtures (no React constructs) must emit byte-identical IR; the adapter must be a strict no-op on non-component code. +- **Corpus** — run compile over KERN's own `app/*.jsx` and a cloned real-world Next.js repo: zero crashes, all ambiguous constructs surface as faults (grep the FAULTS footer), compression ratios recorded per tier for the record. No hard ratio gate — compression is a large-file property and the `min_ir_tokens=600` source-cheaper stub already handles small files. + +## Non-goals (this spec) + +- Next.js concepts: routes, layouts, `use client`/`use server` boundaries, server actions, metadata (Stage 2 spec) +- Type-aware enrichment via the TypeScript compiler API or LSP (Stage 3 spec) +- Vue / Svelte / Angular / Astro adapters +- Declarative `.scm` query-pack architecture +- A custom Tree-sitter React grammar (never needed) diff --git a/skills/kern/scripts/kern_cache.py b/skills/kern/scripts/kern_cache.py index 36fbd7e..252d1cb 100755 --- a/skills/kern/scripts/kern_cache.py +++ b/skills/kern/scripts/kern_cache.py @@ -6,6 +6,7 @@ import argparse import copy import hashlib +import importlib import importlib.metadata import json import os @@ -24,7 +25,7 @@ BASELINE_GENERATOR = "kern-det/0.2" DERIVATION_MARKER = "kern-derivation/semantic16-v1" TSJS_SUFFIXES = {".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx"} -ADDRESSABLE_SYMBOL_KINDS = {"function", "class", "type", "enum", "namespace", "module", "export"} +ADDRESSABLE_SYMBOL_KINDS = {"function", "class", "component", "type", "enum", "namespace", "module", "export"} CACHE_DIRNAME = ".kern" DEFAULT_CONFIG: dict[str, Any] = { "schema": SCHEMA, @@ -546,6 +547,31 @@ def _distribution_version(name: str) -> str: return "unknown" +def _module_source_fingerprint(module_name: str, fallback: Path | None = None) -> str: + """Return a module source digest, or a deterministic availability marker. + + Optional compiler adapters are still fingerprint inputs when they cannot be + imported: if their source is present, hash the source directly; otherwise + record a stable marker instead of making cache validation fail. + """ + path = fallback + try: + module = importlib.import_module(module_name) + module_file = getattr(module, "__file__", None) + if module_file: + path = Path(module_file) + except Exception: + pass + if path is None: + return "unavailable" + try: + return sha256_file(path) + except FileNotFoundError: + return "missing" + except (OSError, ValueError): + return "unavailable" + + def compiler_fingerprint( source: Path, config: dict[str, Any], @@ -562,6 +588,10 @@ def compiler_fingerprint( "python": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}", } if suffix in TSJS_SUFFIXES: + try: + react_fallback = compiler_path.with_name("kern_react.py") + except ValueError: + react_fallback = None capabilities.update( { "tree-sitter": _distribution_version("tree-sitter"), @@ -573,6 +603,9 @@ def compiler_fingerprint( capabilities["tsjs"] = kern_compile.tsjs_capability_fingerprint() except Exception: capabilities["tsjs"] = "unavailable" + capabilities["kern-react-sha256"] = _module_source_fingerprint( + "kern_react", react_fallback + ) payload = { "codec": CODEC_VERSION, "generator": BASELINE_GENERATOR, diff --git a/skills/kern/scripts/kern_compile.py b/skills/kern/scripts/kern_compile.py index 2d84628..5a2236d 100644 --- a/skills/kern/scripts/kern_compile.py +++ b/skills/kern/scripts/kern_compile.py @@ -11,7 +11,7 @@ import importlib.metadata import json import re -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace CODEC_VERSION = "kern-il/0.2" GENERATOR = "kern-det/0.2" @@ -258,6 +258,8 @@ class Symbol: effects: dict = field(default_factory=dict) # effect -> list of via names ([] = direct) raises_all: dict = field(default_factory=dict) # exception -> list of via names unknown_calls: int = 0 + react: dict = field(default_factory=dict) # React adapter payload (kern_react) + redact_body: bool = False # scrub rendered body metadata @dataclass @@ -482,7 +484,7 @@ def _resolved_local(module: ModuleIR, caller: Symbol, call_name: str, def propagate(module: ModuleIR) -> None: - funcs = [s for s in module.symbols if s.kind == "function"] + funcs = [s for s in module.symbols if s.kind in ("function", "component")] by_name: dict[str, list[Symbol]] = {} for s in funcs: by_name.setdefault(s.name, []).append(s) @@ -734,12 +736,14 @@ def _function_lines(s: Symbol, level: int, tier: str, faults: list) -> list: f"@L{s.span[0]}-{s.span[1]} ^{handle} ~{tier}"] if s.decorators: lines.append(" DECORATORS " + ", ".join(s.decorators)) + display_call = (lambda call: sanitize_string(call, secret_hint=True) + if s.redact_body else call) if level <= 2: if s.calls: # Calls are part of the L1/L2 fidelity contract. If the complete # deduplicated set is no longer economical, the file-level size # policy should select source instead of silently discarding facts. - lines.append(" CALLS " + ", ".join(s.calls)) + lines.append(" CALLS " + ", ".join(display_call(call) for call in s.calls)) else: covered = "\n".join(op.detail for op in s.flow) leftover = [ @@ -747,7 +751,7 @@ def _function_lines(s: Symbol, level: int, tier: str, faults: list) -> list: if not re.search(rf"(? list: if raises: lines.append(" RAISES " + raises) if level >= 2: - for op in s.flow: - # L2 is structure-only: bare CALL carries no name (CALLS has them), - # so only risk-tagged calls earn a line at this tier. - if level == 2 and op.op == "CALL" and not op.risk: - continue - pad = " " * (op.depth + 2) - piece = op.op - if level == 3 and op.detail: - piece += f" {op.detail}" - elif level == 2 and op.op in ("CATCH", "NESTED", "CASE") and op.detail: - piece += f" {op.detail}" - if level == 3 and op.binds: - piece += f" -> {op.binds}" - risk = op.risk - if not risk and level == 3 and any(m in op.detail for m in _ELIDED): - risk = "elided-literal" - if risk: - piece += f" !FAULT({risk})" - faults.append(f"{risk}(L{op.line})") - lines.append(pad + piece) + lines.extend(flow_lines(s, level, tier, faults)) + return lines + + +def flow_lines(s: Symbol, level: int, tier: str, faults: list, ops=None) -> list: + """Render flow ops at the given tier. `ops` overrides `s.flow` so callers + (the React adapter) can pre-filter without copying the symbol.""" + lines = [] + for op in (s.flow if ops is None else ops): + # L2 is structure-only: bare CALL carries no name (CALLS has them), + # so only risk-tagged calls earn a line at this tier. + if level == 2 and op.op == "CALL" and not op.risk: + continue + pad = " " * (op.depth + 2) + piece = op.op + if level == 3 and op.detail: + piece += f" {op.detail}" + elif level == 2 and op.op in ("CATCH", "NESTED", "CASE") and op.detail: + piece += f" {op.detail}" + if level == 3 and op.binds: + piece += f" -> {op.binds}" + risk = op.risk + if not risk and level == 3 and any(m in op.detail for m in _ELIDED): + risk = "elided-literal" + if risk: + piece += f" !FAULT({risk})" + faults.append(f"{risk}(L{op.line})") + lines.append(pad + piece) return lines @@ -899,6 +911,14 @@ def add_type_literals(node): for child in node.named_children: add_type_literals(child) + def unwrap_ts_value(node): + while node is not None and node.type in { + "parenthesized_expression", "as_expression", "satisfies_expression", + "non_null_expression", + }: + node = node.named_children[0] if node.named_children else None + return node + def walk(node): node_type = node.type if node_type in {"required_parameter", "optional_parameter", "variable_declarator", @@ -909,16 +929,28 @@ def walk(node): add(value) elif named_secret(name): add_type_literals(field_node(node, "type")) - elif node_type in {"assignment_expression", "augmented_assignment_expression", "assignment_pattern"}: + elif node_type in { + "assignment_expression", "augmented_assignment_expression", + "assignment_pattern", "object_assignment_pattern", + }: left = field_node(node, "left") right = field_node(node, "right") if named_secret(left) and right is not None: add(right) - elif node_type == "pair": + elif node_type in {"pair", "pair_pattern"}: key = field_node(node, "key") value = field_node(node, "value") if named_secret(key) and value is not None: - add(value) + if node_type == "pair_pattern": + if value.type in {"assignment_pattern", "object_assignment_pattern"}: + value = field_node(value, "right") + elif value.type not in {"object_pattern", "array_pattern"}: + # `password: local` aliases a binding but carries no + # value to redact; nested patterns are conservatively + # redacted as a unit when the outer property is secret. + value = None + if value is not None: + add(value) elif node_type == "enum_assignment": name = field_node(node, "name") value = field_node(node, "value") @@ -929,6 +961,28 @@ def walk(node): type_node = field_node(node, "type") if named_secret(name) and type_node is not None: add_type_literals(type_node) + elif node_type == "jsx_attribute": + name = field_node(node, "name") + value = field_node(node, "value") + if name is None and node.named_children: + name = node.named_children[0] + if value is None and len(node.named_children) > 1: + value = node.named_children[1] + if named_secret(name) and value is not None: + add(value) + elif node_type in { + "method_definition", "method_signature", "abstract_method_signature", + }: + name = field_node(node, "name") + if named_secret(name): + # The method name is useful structure, but a secret-named + # method's defaults, generic/return types, and body are all + # secret-bearing value positions. + for child in node.named_children: + if (name is None + or child.start_byte != name.start_byte + or child.end_byte != name.end_byte): + add(child) elif node_type in {"call_expression", "new_expression"}: callee = field_node(node, "function") or field_node(node, "constructor") if named_secret(callee): @@ -936,6 +990,23 @@ def walk(node): if arguments is not None: for argument in arguments.named_children: add(argument) + if callee is not None and callee.type == "member_expression": + prop = field_node(callee, "property") + if prop is not None and raw_text(prop) == "map": + arguments = field_node(node, "arguments") + callback = None + if arguments is not None and arguments.named_children: + candidate = unwrap_ts_value(arguments.named_children[0]) + if (candidate is not None + and candidate.type in { + "arrow_function", "function_expression", + }): + callback = candidate + if callback is not None: + params = (field_node(callback, "parameter") + or field_node(callback, "parameters")) + if named_secret(params): + add(field_node(callee, "object")) for child in node.named_children: walk(child) @@ -1109,8 +1180,16 @@ def add(current, op, detail="", binds="", risk=""): ops.extend(flow(c, depth, budget - len(ops))) return ops[:budget] + fn_nodes: list = [] + def function_symbol(node, qualified, decorators=None): decorators = list(decorators or []) + method_name = (field_node(node, "name") + if node.type in { + "method_definition", "method_signature", + "abstract_method_signature", + } else None) + secret_method = bool(method_name is not None and SECRET_NAME.search(raw_text(method_name))) calls, raises, effects = [], [], {} body = field_node(node, "body") if body is not None: @@ -1121,6 +1200,8 @@ def function_symbol(node, qualified, decorators=None): a, b = span_with_decorators(node, decorators) is_async = any(ch.type == "async" for ch in node.children) signature = ntext(params, 240) if params is not None else "" + if secret_method and params is not None: + signature = ntext(params, 240, secret_hint=True) if signature.startswith("(") and signature.endswith(")"): signature = signature[1:-1] if body is None: @@ -1130,14 +1211,25 @@ def function_symbol(node, qualified, decorators=None): else: body_flow = [FlowOp("RET", ntext(body, 140), depth=0, line=body.start_point[0] + 1, risk=ts_risk(body))] - return Symbol( + if secret_method: + body_flow = [replace( + op, + detail=(sanitize_string(op.detail, secret_hint=True) + if op.detail else ""), + ) for op in body_flow] + raises = [sanitize_string(item, secret_hint=True) for item in raises] + sym = Symbol( kind="function", name=qualified, span=(a, b), signature=signature, - returns=ntext(rtype, 60).lstrip(": ") if rtype is not None else "", + returns=(ntext(rtype, 60, secret_hint=secret_method).lstrip(": ") + if rtype is not None else ""), slice8=slice_sha8(text, a, b), calls=calls, raises=raises, flow=body_flow, is_async=is_async, effects=effects, decorators=[ntext(decorator, 100) for decorator in decorators], + redact_body=secret_method, ) + fn_nodes.append((sym, node)) + return sym symbols: list[Symbol] = [] unsupported_runtime: list[tuple[str, int]] = [] @@ -1145,11 +1237,27 @@ def function_symbol(node, qualified, decorators=None): def add_simple(kind_name, name, node, detail="", decorators=None, bases=""): decorators = list(decorators or []) a, b = span_with_decorators(node, decorators) - symbols.append(Symbol( + sym = Symbol( kind=kind_name, name=name, detail=detail, bases=bases, span=(a, b), slice8=slice_sha8(text, a, b), decorators=[ntext(decorator, 100) for decorator in decorators], - )) + ) + symbols.append(sym) + return sym + + # Wrapper callbacks are only function-like when the React adapter can + # prove they render JSX. Keep the ordinary const symbol as the origin so + # probing memo/forwardRef is a no-op for non-components; successful probes + # are swapped in after React lowering. + wrapper_probes: list[tuple[Symbol, Symbol, object]] = [] + + def unwrap_ts_value(value): + while value is not None and value.type in { + "parenthesized_expression", "as_expression", "satisfies_expression", + "non_null_expression", + }: + value = value.named_children[0] if value.named_children else None + return value def commonjs_assignment(node, prefix) -> bool: left = field_node(node, "left") @@ -1206,8 +1314,32 @@ def visit(node, prefix="", in_class=False, decorators=None): name_node = field_node(declaration, "name") value = field_node(declaration, "value") name = prefix + ntext(name_node, 80) - if value is not None and value.type in {"arrow_function", "function_expression", "generator_function"}: + if value is not None and value.type in { + "arrow_function", "function_expression", "generator_function", + }: symbols.append(function_symbol(value, name)) + continue + probe_value = unwrap_ts_value(value) + wrapper, inner = "", None + if probe_value is not None and probe_value.type == "call_expression": + callee = field_node(probe_value, "function") + wrapper = ntext(callee, 60) if callee is not None else "" + if wrapper in ("memo", "forwardRef", "React.memo", "React.forwardRef"): + arguments = field_node(probe_value, "arguments") + if arguments is not None and arguments.named_children: + first = unwrap_ts_value(arguments.named_children[0]) + if (first is not None + and first.type in ("arrow_function", "function_expression")): + inner = first + if inner is not None: + detail = "=" + ntext( + value, 160, secret_hint=bool(SECRET_NAME.search(name)) + ) + origin = add_simple("const", name, declaration, detail) + probe = function_symbol(inner, name) + probe.decorators = [wrapper.split(".")[-1]] + owner = node.parent if node.parent.type == "export_statement" else node + wrapper_probes.append((origin, probe, owner)) else: detail = "=" + ntext(value, 160, secret_hint=bool(SECRET_NAME.search(name))) if value is not None else "" add_simple("const", name, declaration, detail) @@ -1292,6 +1424,27 @@ def visit(node, prefix="", in_class=False, decorators=None): visit(tree.root_node) + frontend = "tree-sitter" + if kind in ("javascript", "tsx"): + # JSX-capable grammars only: plain typescript has no JSX productions. + import kern_react + if kern_react.lower_components(fn_nodes, ntext=ntext, flow_fn=flow): + frontend = "tree-sitter+react" + for origin, probe, declaration in wrapper_probes: + if probe.kind != "component": + continue + # The component is owned by the complete declaration, not merely + # its first wrapper argument. Comparator and other wrapper-arg + # edits must therefore change its exact and semantic handles, and + # exact-source faults must cover the complete declaration. + a, b = span(declaration) + probe.span = (a, b) + probe.slice8 = slice_sha8(text, a, b) + for index, symbol in enumerate(symbols): + if symbol is origin: + symbols[index] = probe + break + all_nodes = list(walk_nodes(tree.root_node)) lines = text.split("\n") if lines and lines[-1] == "": @@ -1322,7 +1475,7 @@ def first_error_line(n): line = 1 parse_error = f"tree-sitter reported syntax errors (first at L{line})" - return ModuleIR(lang_name, "tree-sitter", symbols, omit, parse_error=parse_error) + return ModuleIR(lang_name, frontend, symbols, omit, parse_error=parse_error) def emit_il(module: ModuleIR, source_rel: str, source_sha256: str, @@ -1374,6 +1527,8 @@ def emit_il(module: ModuleIR, source_rel: str, source_sha256: str, handle = s.semantic8 or s.slice8 detail = (" " + s.detail) if s.detail else "" out.append(f"{label} {s.name}{detail} @L{s.span[0]}-{s.span[1]} ^{handle}") + if any(s.kind == "component" for s in module.symbols): + import kern_react for s in module.symbols: if s.kind == "class": handle = s.semantic8 or s.slice8 @@ -1383,6 +1538,9 @@ def emit_il(module: ModuleIR, source_rel: str, source_sha256: str, elif s.kind == "function": out.append("") out.extend(_function_lines(s, level, tier, faults)) + elif s.kind == "component": + out.append("") + out.extend(kern_react.component_lines(s, level, tier, faults)) omit = " ".join(f"{k}={v}" for k, v in sorted(module.omit.items())) out.extend([ "", diff --git a/skills/kern/scripts/kern_react.py b/skills/kern/scripts/kern_react.py new file mode 100644 index 0000000..0f78028 --- /dev/null +++ b/skills/kern/scripts/kern_react.py @@ -0,0 +1,1204 @@ +#!/usr/bin/env python3 +"""React semantic adapter: upgrades tree-sitter function symbols to KERN-IL components.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field, replace + +# Safe: kern_compile imports kern_react only lazily inside functions, so there +# is no cycle at module-exec time. +from kern_compile import ( + SECRET_NAME, + SECRET_VALUE, + FlowOp, + _render_provenanced, + flow_lines, + one_line_text, + sanitize_string, + sha256_hex, +) + +JSX_TYPES = {"jsx_element", "jsx_self_closing_element", "jsx_fragment"} +HOOK_RE = re.compile(r"^use[A-Z]\w*$") +COMPONENT_NAME_RE = re.compile(r"^[A-Z]") +COMPONENT_FN_TYPES = {"arrow_function", "function_expression", "function_declaration"} +FN_TYPES = {"arrow_function", "function_expression", "function_declaration", + "generator_function", "generator_function_declaration", "method_definition"} +NESTED_SCOPE_TYPES = FN_TYPES | { + "class", "class_declaration", "abstract_class_declaration", +} +_STR_ASSIGN = re.compile( + r"(\w+)(\s*=\s*)(\"(?:[^\"\\]|\\.)*\"|'(?:[^'\\]|\\.)*'|`[^`]*`)") + + +def _scrub_named_secrets(text: str) -> str: + """Redact string literals assigned to secret-named identifiers or JSX + attributes inside already-rendered text (signatures, flow-op details).""" + def sub(m): + if SECRET_NAME.search(m.group(1)): + return m.group(1) + m.group(2) + sanitize_string(m.group(3), secret_hint=True) + return m.group(0) + return _STR_ASSIGN.sub(sub, text) + + +def _raw_text(node) -> str: + if node is None: + return "" + value = getattr(node, "text", b"") + if isinstance(value, bytes): + return value.decode("utf-8", "replace") + return str(value) + + +def _cap_text(text: str, cap: int) -> str: + if len(text) <= cap: + return text + digest = sha256_hex(text.encode("utf-8", "surrogatepass"))[:12] + return text[:max(0, cap - 24)] + f"…" + + +def _secret_pattern_values(root, inherited_secret=False) -> list: + """Return default-value nodes whose binding/property name is secret. + + Tree-sitter's generic text sanitizer does not treat + ``object_assignment_pattern`` as an assignment, and a secret property may + bind to a differently named local (``password: value = make(...)``). Walk + the pattern itself so compound defaults and nested destructuring are + handled by syntax rather than by rendered-text regexes. + """ + if root is None: + return [] + out = [] + + def walk(node, secret=False): + if node.type in {"string", "template_string"}: + if SECRET_VALUE.search(_raw_text(node)): + out.append(node) + return + if node.type in {"call_expression", "new_expression"}: + callee = (node.child_by_field_name("function") + or node.child_by_field_name("constructor")) + if callee is not None and SECRET_NAME.search(_raw_text(callee)): + arguments = node.child_by_field_name("arguments") + if arguments is not None: + out.extend(arguments.named_children) + return + if node.type in {"object_assignment_pattern", "assignment_pattern"}: + left = node.child_by_field_name("left") + right = node.child_by_field_name("right") + named = bool(left is not None and SECRET_NAME.search(_raw_text(left))) + if right is not None and (secret or named): + out.append(right) + return + if left is not None: + walk(left, secret) + if right is not None: + walk(right, secret) + return + if node.type == "pair_pattern": + key = node.child_by_field_name("key") + value = node.child_by_field_name("value") + named = bool(key is not None and SECRET_NAME.search(_raw_text(key))) + if value is not None: + walk(value, secret or named) + return + for child in node.named_children: + walk(child, secret) + + walk(root, inherited_secret) + return out + + +def _render_with_redactions(root, redactions, cap: int) -> str: + """Render *root* while replacing selected descendant nodes atomically.""" + if root is None: + return "" + raw = getattr(root, "text", b"") + if not isinstance(raw, bytes): + raw = str(raw).encode("utf-8", "surrogatepass") + ranges = [] + for node in redactions: + if node.start_byte < root.start_byte or node.end_byte > root.end_byte: + continue + ranges.append((node.start_byte - root.start_byte, + node.end_byte - root.start_byte)) + selected = [] + for start, end in sorted(ranges, key=lambda item: (item[0], -(item[1] - item[0]))): + if selected and start >= selected[-1][0] and end <= selected[-1][1]: + continue + if selected and start < selected[-1][1]: + continue + selected.append((start, end)) + pieces, cursor = [], 0 + for start, end in selected: + pieces.append(raw[cursor:start]) + value = raw[start:end].decode("utf-8", "replace") + pieces.append(sanitize_string(value, secret_hint=True).encode("utf-8")) + cursor = end + pieces.append(raw[cursor:]) + return _cap_text(one_line_text(b"".join(pieces).decode("utf-8", "replace")), cap) + + +def _append_fault(faults, risk: str, line: int): + item = (risk, line) + if item not in faults: + faults.append(item) + + +@dataclass +class HookUse: + kind: str # STATE | CTX | REF | HOOK | EFFECT + detail: str + line: int + risk: str = "" + inherited_risk: str = "" + flow: list = field(default_factory=list) # EFFECT body ops for L3 + + +@dataclass +class EventUse: + target: str # "Card.onClick" + action: str # "set open=true" | callee name + line: int + + +@dataclass +class RenderNode: + tag: str # "Card" | "IF open" | "FOR item in items" | "ELSE" | "{expr}" | text + attrs: str = "" + line: int = 0 + is_component: bool = False + is_structure: bool = False # IF/ELSE/FOR — survives L2 collapse + risk: str = "" + children: list = field(default_factory=list) + + +def _unwrap_parens(n): + while n is not None and n.type in { + "parenthesized_expression", "as_expression", "satisfies_expression", + "non_null_expression", + }: + inner = n.named_children + n = inner[0] if inner else None + return n + + +def _function_argument(arguments): + """Return a function-valued first argument after TS expression wrappers.""" + if arguments is None or not arguments.named_children: + return None + candidate = _unwrap_parens(arguments.named_children[0]) + if (candidate is not None + and candidate.type in {"arrow_function", "function_expression"}): + return candidate + return None + + +def _jsx_bearing(u) -> bool: + """True for a JSX node, or a one-level conditional expression yielding JSX: + `cond ? : ` or `cond && `. One level only — deterministic.""" + if u is None: + return False + if u.type in JSX_TYPES: + return True + if u.type == "ternary_expression": + cons = _unwrap_parens(u.child_by_field_name("consequence")) + alt = _unwrap_parens(u.child_by_field_name("alternative")) + return ((cons is not None and cons.type in JSX_TYPES) + or (alt is not None and alt.type in JSX_TYPES)) + if u.type == "binary_expression": + op = u.child_by_field_name("operator") + right = _unwrap_parens(u.child_by_field_name("right")) + return (op is not None and op.type == "&&" + and right is not None and right.type in JSX_TYPES) + return False + + +def _returns_jsx(fn_node) -> bool: + body = fn_node.child_by_field_name("body") + if body is None: + return False + if body.type != "statement_block": + return _jsx_bearing(_unwrap_parens(body)) + stack = list(body.named_children) + while stack: + n = stack.pop() + if n.type in NESTED_SCOPE_TYPES: + continue # returns inside nested functions don't count + if n.type == "return_statement": + for ch in n.named_children: + if _jsx_bearing(_unwrap_parens(ch)): + return True + stack.extend(n.named_children) + return False + + +def _extract_props(fn_node, ntext) -> list: + params = fn_node.child_by_field_name("parameters") + if params is None: + single = fn_node.child_by_field_name("parameter") + first = single + else: + named = params.named_children + first = named[0] if named else None + if first is None: + return [] + if first.type in ("required_parameter", "optional_parameter"): + pattern = first.child_by_field_name("pattern") + if pattern is not None: + first = pattern + if first.type == "object_pattern": + out = [] + for p in first.named_children: + if p.type == "shorthand_property_identifier_pattern": + out.append(ntext(p, 60)) + elif p.type == "object_assignment_pattern": + left = p.child_by_field_name("left") + right = p.child_by_field_name("right") + left_txt = ntext(left, 60) + values = _secret_pattern_values(p) + right_txt = (_render_with_redactions(right, [right], 60) + if right in values else ntext(right, 60)) + out.append(f"{left_txt}={right_txt}") + elif p.type == "pair_pattern": + values = _secret_pattern_values(p) + out.append(_render_with_redactions(p, values, 60) + if values else ntext(p, 60)) + elif p.type == "rest_pattern": + out.append(ntext(p, 60)) + return out + return [ntext(first, 60)] + + +EFFECT_HOOKS = {"useEffect", "useLayoutEffect"} + + +def _optional_chain_reaches_result(node) -> bool: + """Whether an optional-chain segment can short-circuit this expression. + + Follow only the receiver/call chain. Optional expressions inside computed + property keys are evaluated values; they do not make the outer call + conditional. + """ + # Parentheses terminate optional-chain short-circuiting. Type-only TS + # wrappers are runtime-transparent, so follow those but never cross an + # explicit grouping boundary. + while node is not None and node.type in { + "as_expression", "satisfies_expression", "non_null_expression", + }: + node = node.named_children[0] if node.named_children else None + if node is None: + return False + if node.type == "parenthesized_expression": + return False + if node.type == "call_expression": + return _is_optional_call(node) + if node.type not in {"member_expression", "subscript_expression"}: + return False + if any(child.type in {"optional_chain", "?."} for child in node.children): + return True + return _optional_chain_reaches_result(node.child_by_field_name("object")) + + +def _is_optional_call(node) -> bool: + if node is None or node.type != "call_expression": + return False + if any(child.type == "?." for child in node.children): + return True + return _optional_chain_reaches_result(node.child_by_field_name("function")) + + +def _is_conditional_expression(node) -> bool: + if node is None: + return False + if node.type == "ternary_expression" or _is_optional_call(node): + return True + if node.type in {"binary_expression", "augmented_assignment_expression"}: + operator = node.child_by_field_name("operator") + return operator is not None and operator.type in { + "&&", "||", "??", "&&=", "||=", "??=", + } + return False + + +def _call_parts(value, ntext): + """(callee_text, args_nodes) for a call_expression, else (None, []).""" + value = _unwrap_parens(value) + if value is None or value.type != "call_expression": + return None, [] + callee = value.child_by_field_name("function") + args = value.child_by_field_name("arguments") + return (ntext(callee, 80) if callee is not None else None, + list(args.named_children) if args is not None else []) + + +def _hook_info(callee: str, aliases: dict, shadowed=()) -> tuple[str, bool, bool]: + tail = callee.split(".")[-1] + if "." not in callee: + if callee in shadowed: + return callee, False, False + canonical = aliases.get(callee, callee) + return canonical, canonical != callee, bool(HOOK_RE.match(canonical)) + # A renamed import is a local binding, never a property on an unrelated + # object. Keep namespace-style canonical hook calls conservative, but do + # not let `obj.useS()` inherit `import { useState as useS }` semantics. + if tail in aliases: + return tail, False, False + return tail, True, bool(HOOK_RE.match(tail)) + + +def _react_hook_aliases(fn_nodes, ntext) -> dict: + """Map renamed named imports from React back to their canonical hook.""" + if not fn_nodes: + return {} + root = fn_nodes[0][1] + while root.parent is not None: + root = root.parent + aliases = {} + for statement in root.named_children: + if statement.type != "import_statement": + continue + source = next((child for child in reversed(statement.named_children) + if child.type == "string"), None) + if source is None or _raw_text(source).strip("'\"") != "react": + continue + stack = list(statement.named_children) + while stack: + node = stack.pop() + if node.type == "import_specifier": + original = node.child_by_field_name("name") + alias = node.child_by_field_name("alias") + original_txt = ntext(original, 60) + local_txt = ntext(alias, 60) if alias is not None else original_txt + if HOOK_RE.match(original_txt) and local_txt != original_txt: + aliases[local_txt] = original_txt + continue + stack.extend(node.named_children) + return aliases + + +def _shadowed_hook_aliases(fn_node, aliases: dict) -> set[str]: + """Return imported aliases rebound by this component's function scope.""" + if not aliases: + return set() + names = set() + + def bindings(node): + if node is None or node.type == "type_annotation": + return + if node.type in {"identifier", "shorthand_property_identifier_pattern"}: + text = _raw_text(node) + if text in aliases: + names.add(text) + return + if node.type in {"required_parameter", "optional_parameter"}: + bindings(node.child_by_field_name("pattern")) + return + if node.type in {"assignment_pattern", "object_assignment_pattern"}: + bindings(node.child_by_field_name("left")) + return + if node.type == "pair_pattern": + bindings(node.child_by_field_name("value")) + return + for child in node.named_children: + bindings(child) + + bindings(fn_node.child_by_field_name("parameters") + or fn_node.child_by_field_name("parameter")) + body = fn_node.child_by_field_name("body") + if body is not None and body.type == "statement_block": + for statement in body.named_children: + if statement.type in {"lexical_declaration", "variable_declaration"}: + for declaration in statement.named_children: + if declaration.type == "variable_declarator": + bindings(declaration.child_by_field_name("name")) + elif statement.type in {"function_declaration", "class_declaration"}: + bindings(statement.child_by_field_name("name")) + return names + + +def _node_risk(node, ntext) -> str: + stack = [node] + while stack: + child = stack.pop() + if child.type == "regex": + return "regex" + if child.type in {"call_expression", "new_expression"}: + callee = (child.child_by_field_name("function") + or child.child_by_field_name("constructor")) + name = ntext(callee, 100) + if re.search(r"(?:^|\.)(?:createHash|createHmac|subtle|randomBytes)$", name): + return "crypto" + if re.search(r"(?:^|\.)(?:Worker|Atomics|SharedArrayBuffer)$", name): + return "concurrency" + if re.search(r"(?:^|\.)(?:RegExp|match|replace|search|split)$", name): + return "regex" + if child.type == "binary_expression": + if any(token.type in {"**", "<<", ">>", ">>>"} for token in child.children): + return "math" + stack.extend(child.named_children) + return "" + + +def _expression_flow(node, ntext, flow_fn=None) -> list: + node = _unwrap_parens(node) + if node is None: + return [] + if node.type in {"arrow_function", "function_expression"}: + body = node.child_by_field_name("body") + if body is None: + return [] + if body.type == "statement_block" and flow_fn is not None: + return flow_fn(body) + return _expression_flow(body, ntext, flow_fn) + if node.type in {"call_expression", "new_expression", "await_expression"}: + return [FlowOp("CALL", ntext(node, 120), line=node.start_point[0] + 1, + risk=_node_risk(node, ntext))] + return [FlowOp("RET", ntext(node, 120), line=node.start_point[0] + 1, + risk=_node_risk(node, ntext))] + + +def _extract_hooks(body, react, ntext, flow_fn, aliases=None, shadowed=None): + aliases = aliases or {} + shadowed = shadowed or set() + hooks, setters, faults = react["hooks"], {}, react["faults"] + for stmt in body.named_children: + line = stmt.start_point[0] + 1 + if stmt.type in ("lexical_declaration", "variable_declaration"): + for d in stmt.named_children: + if d.type != "variable_declarator": + continue + name_node = d.child_by_field_name("name") + value = d.child_by_field_name("value") + _fault_nested_conditional_hooks( + value, faults, ntext, aliases, shadowed) + callee, args = _call_parts(value, ntext) + if callee is None: + continue + tail, is_alias, is_hook = _hook_info(callee, aliases, shadowed) + if not is_hook: + continue + risk = "aliased-hook" if is_alias else "" + inherited_risk = _node_risk(value, ntext) + if risk: + _append_fault(faults, risk, line) + name_txt = ntext(name_node, 60) + + def bound(value_txt, name=None): + # Name-hinted secret: redact the value bound to it. + if SECRET_NAME.search(name if name is not None else name_txt): + return sanitize_string(value_txt, secret_hint=True) + return value_txt + + if tail == "useState" and name_node.type == "array_pattern": + elems = [ntext(e, 40) for e in name_node.named_children] + state = elems[0] if elems else "?" + init = ntext(args[0], 80) if args else "undefined" + if len(elems) > 1: + setters[elems[1]] = state + hooks.append(HookUse("STATE", f"{state}={bound(init, state)}", + line, risk, inherited_risk)) + elif tail == "useReducer": + hooks.append(HookUse("STATE", f"{name_txt}={bound(ntext(value, 120))}", + line, risk, inherited_risk)) + elif tail == "useContext": + hooks.append(HookUse("CTX", f"{name_txt}={bound(ntext(value, 80))}", + line, risk, inherited_risk)) + elif tail == "useRef": + hooks.append(HookUse("REF", name_txt, line, risk, inherited_risk)) + else: + hooks.append(HookUse("HOOK", f"{name_txt}={bound(ntext(value, 120))}", + line, risk, inherited_risk)) + elif stmt.type == "expression_statement" and stmt.named_children: + value = _unwrap_parens(stmt.named_children[0]) + if value is None: + continue + _fault_nested_conditional_hooks( + value, faults, ntext, aliases, shadowed) + if _is_conditional_expression(value): + continue + callee, args = _call_parts(value, ntext) + if callee is None: + continue + tail, is_alias, is_hook = _hook_info(callee, aliases, shadowed) + if not is_hook: + continue + if tail in EFFECT_HOOKS: + risk = "aliased-hook" if is_alias else "" + inherited_risk = _node_risk(value, ntext) + if risk: + _append_fault(faults, risk, line) + deps = f"deps={ntext(args[1], 100)}" if len(args) >= 2 else "deps=EVERY-RENDER" + ops = [] + callback = _unwrap_parens(args[0]) if args else None + if callback is not None and callback.type in ("arrow_function", "function_expression"): + cb_body = callback.child_by_field_name("body") + if cb_body is not None: + ops = (flow_fn(cb_body) if cb_body.type == "statement_block" + else _expression_flow(cb_body, ntext, flow_fn)) + hooks.append(HookUse("EFFECT", deps, line, risk, + inherited_risk, flow=ops)) + else: + risk = "aliased-hook" if is_alias else "" + inherited_risk = _node_risk(value, ntext) + if risk: + _append_fault(faults, risk, line) + hooks.append(HookUse("HOOK", ntext(value, 120), line, + risk, inherited_risk)) + else: + if stmt.type in { + "if_statement", "for_statement", "for_in_statement", + "while_statement", "do_statement", "switch_statement", + "try_statement", + }: + _fault_conditional_hooks(stmt, faults, ntext, aliases, shadowed) + _fault_nested_conditional_hooks( + stmt, faults, ntext, aliases, shadowed) + react["setters"] = setters + + +def _fault_conditional_hooks(node, faults, ntext, aliases=None, shadowed=None): + if node is None: + return + aliases = aliases or {} + shadowed = shadowed or set() + stack = [node] + while stack: + n = stack.pop() + if n.type in NESTED_SCOPE_TYPES: + continue + if n.type == "call_expression": + _fault_hook_call(n, faults, ntext, aliases, shadowed) + stack.extend(n.named_children) + + +def _fault_hook_call(node, faults, ntext, aliases=None, shadowed=None): + """Fault *node* itself when it is a hook call, without scanning children.""" + if node is None or node.type != "call_expression": + return + aliases = aliases or {} + shadowed = shadowed or set() + callee = node.child_by_field_name("function") + txt = ntext(callee, 80) if callee is not None else "" + _, is_alias, is_hook = _hook_info(txt, aliases, shadowed) + if is_hook: + line = node.start_point[0] + 1 + _append_fault(faults, "conditional-hook", line) + if is_alias: + _append_fault(faults, "aliased-hook", line) + + +def _fault_nested_conditional_hooks(node, faults, ntext, aliases=None, shadowed=None): + """Find guarded hook calls even when the conditional is nested in a call, + assignment, or JSX expression, while leaving unguarded operands alone.""" + if node is None: + return + stack = [node] + while stack: + current = stack.pop() + if current.type in NESTED_SCOPE_TYPES: + continue + if current.type == "ternary_expression": + condition = current.child_by_field_name("condition") + consequence = current.child_by_field_name("consequence") + alternative = current.child_by_field_name("alternative") + _fault_conditional_hooks( + consequence, faults, ntext, aliases, shadowed) + _fault_conditional_hooks( + alternative, faults, ntext, aliases, shadowed) + if condition is not None: + stack.append(condition) + continue + if current.type in {"binary_expression", "augmented_assignment_expression"}: + operator = current.child_by_field_name("operator") + if operator is not None and operator.type in { + "&&", "||", "??", "&&=", "||=", "??=", + }: + left = current.child_by_field_name("left") + right = current.child_by_field_name("right") + _fault_conditional_hooks( + right, faults, ntext, aliases, shadowed) + if left is not None: + stack.append(left) + continue + if _is_optional_call(current): + # The optional invocation and its arguments are guarded, while + # evaluating the callee expression itself is not. + _fault_hook_call(current, faults, ntext, aliases, shadowed) + arguments = current.child_by_field_name("arguments") + _fault_conditional_hooks( + arguments, faults, ntext, aliases, shadowed) + callee = current.child_by_field_name("function") + if callee is not None: + stack.append(callee) + continue + stack.extend(current.named_children) + + +RENDER_BUDGET = 200 + + +def _jsx_name(el, ntext): + """(name_text, is_component, risk) for a jsx element node.""" + opening = el + if el.type == "jsx_element": + opening = el.named_children[0] if el.named_children else el + name = opening.child_by_field_name("name") + if name is None: + return ("<>", False, "") + txt = ntext(name, 60) + if name.type in ("member_expression", "jsx_namespace_name"): + return (txt, True, "dynamic-component") + if COMPONENT_NAME_RE.match(txt): + return (txt, True, "") + return (txt, False, "") + + +def _jsx_attrs(el, ntext): + """(attrs_text, spread_only, event_attrs, risk) from an opening tag. + event_attrs: list of (attr_name, value_node, secret_name_hint) triples.""" + opening = el + if el.type == "jsx_element": + opening = el.named_children[0] if el.named_children else el + parts, named, spread, events, risk = [], 0, 0, [], "" + for a in opening.named_children: + if a.type == "jsx_attribute": + aname_node = a.named_children[0] if a.named_children else None + aname = ntext(aname_node, 40) if aname_node is not None else "" + value = a.named_children[1] if len(a.named_children) > 1 else None + if re.match(r"^on[A-Z]", aname) and value is not None: + events.append((aname, value, bool(SECRET_NAME.search(aname)))) + continue + callback = value + if callback is not None and callback.type == "jsx_expression": + callback = callback.named_children[0] if callback.named_children else None + callback = _unwrap_parens(callback) + if (callback is not None + and callback.type in {"arrow_function", "function_expression"} + and (aname == "children" or aname.startswith("render") + or _returns_jsx(callback))): + risk = "render-prop" + named += 1 + if SECRET_NAME.search(aname) and value is not None: + parts.append(f"{aname}={sanitize_string(ntext(value, 60), secret_hint=True)}") + else: + parts.append(ntext(a, 60)) + elif a.type == "jsx_expression": # {...spread} + spread += 1 + parts.append(ntext(a, 40).strip("{}")) + return " ".join(parts), (spread > 0 and named == 0), events, risk + + +def _lower_jsx(node, ntext, counter, events_out, element_name=""): + """Lower one JSX node (or jsx child) to list[RenderNode].""" + if counter["n"] >= RENDER_BUDGET: + counter["dropped"] = True + return [] + n = _unwrap_parens(node) + if n is None: + return [] + out = [] + + def make(tag, **kw): + if counter["n"] >= RENDER_BUDGET: + counter["dropped"] = True + return None + counter["n"] += 1 + return RenderNode(tag=tag, line=n.start_point[0] + 1, **kw) + + if n.type in ("jsx_element", "jsx_self_closing_element"): + name, is_comp, risk = _jsx_name(n, ntext) + attrs, spread_only, ev, attr_risk = _jsx_attrs(n, ntext) + if spread_only: + risk = f"{risk}+spread-props" if risk else "spread-props" + if attr_risk: + risk = f"{risk}+{attr_risk}" if risk else attr_risk + rn = make(name, attrs=attrs, is_component=is_comp, risk=risk) + if rn is None: + return [] + for aname, value, secret in ev: + events_out.append((name, aname, value, secret)) + if n.type == "jsx_element": + for ch in n.named_children[1:-1]: + rn.children.extend(_lower_jsx(ch, ntext, counter, events_out, name)) + out.append(rn) + elif n.type == "jsx_fragment": + for ch in n.named_children: + out.extend(_lower_jsx(ch, ntext, counter, events_out, element_name)) + elif n.type == "jsx_text": + txt = ntext(n, 60) + if txt: + rn = make(txt) + if rn is not None: + out.append(rn) + elif n.type == "jsx_expression": + inner = n.named_children[0] if n.named_children else None + out.extend(_lower_expr(inner, ntext, counter, events_out, element_name)) + elif n.type in ("binary_expression", "ternary_expression", "call_expression", + "arrow_function", "function_expression"): + out.extend(_lower_expr(n, ntext, counter, events_out, element_name)) + return out + + +def _jsx_returns(body) -> list: + """Return ``(return_node, jsx_expression)`` pairs outside nested scopes.""" + found = [] + for node in _return_nodes(body): + for child in node.named_children: + value = _unwrap_parens(child) + if _jsx_bearing(value): + found.append((node, value)) + break + return found + + +def _return_nodes(body) -> list: + """Return all return statements outside nested function/class scopes.""" + found = [] + stack = list(body.named_children) + while stack: + node = stack.pop() + if node.type in NESTED_SCOPE_TYPES: + continue + if node.type == "return_statement": + found.append(node) + continue + stack.extend(node.named_children) + return sorted(found, key=lambda node: node.start_byte) + + +def _lower_expr(n, ntext, counter, events_out, element_name): + n = _unwrap_parens(n) + if n is None: + return [] + if counter["n"] >= RENDER_BUDGET: + counter["dropped"] = True + return [] + + def make(tag, **kw): + if counter["n"] >= RENDER_BUDGET: + counter["dropped"] = True + return None + counter["n"] += 1 + return RenderNode(tag=tag, line=n.start_point[0] + 1, **kw) + + if n.type in JSX_TYPES: + return _lower_jsx(n, ntext, counter, events_out, element_name) + if n.type == "binary_expression": + op = n.child_by_field_name("operator") + right = _unwrap_parens(n.child_by_field_name("right")) + if op is not None and op.type == "&&" and right is not None and right.type in JSX_TYPES: + left = n.child_by_field_name("left") + rn = make(f"IF {ntext(left, 80)}", is_structure=True) + if rn is None: + return [] + rn.children = _lower_jsx(right, ntext, counter, events_out, element_name) + return [rn] + elif n.type == "ternary_expression": + cond = n.child_by_field_name("condition") + cons = _unwrap_parens(n.child_by_field_name("consequence")) + alt = _unwrap_parens(n.child_by_field_name("alternative")) + branches = [] + if cons is not None and cons.type in JSX_TYPES: + rn = make(f"IF {ntext(cond, 80)}", is_structure=True) + if rn is not None: + rn.children = _lower_jsx(cons, ntext, counter, events_out, element_name) + branches.append(rn) + if alt is not None and alt.type in JSX_TYPES: + el = make("ELSE", is_structure=True) + if el is not None: + el.children = _lower_jsx(alt, ntext, counter, events_out, element_name) + branches.append(el) + if branches: + return branches + elif n.type == "call_expression": + callee = n.child_by_field_name("function") + if callee is not None and callee.type == "member_expression": + prop = callee.child_by_field_name("property") + if prop is not None and ntext(prop, 20) == "map": + receiver = callee.child_by_field_name("object") + args = n.child_by_field_name("arguments") + cb = _function_argument(args) + if cb is not None: + params = cb.child_by_field_name("parameters") + single = cb.child_by_field_name("parameter") + if single is not None: + param_txt = ntext(single, 40) + elif params is not None and params.named_children: + param_txt = ntext(params.named_children[0], 40) + else: + param_txt = "_" + receiver_txt = ntext(receiver, 60) + if SECRET_NAME.search(param_txt): + receiver_txt = sanitize_string( + ntext(receiver, 10000), secret_hint=True) + rn = make(f"FOR {param_txt} in {receiver_txt}", is_structure=True) + if rn is None: + return [] + body = cb.child_by_field_name("body") + render_body = _unwrap_parens(body) + if render_body is not None and render_body.type == "statement_block": + returns = _jsx_returns(render_body) + all_returns = _return_nodes(render_body) + if returns: + _, render_body = returns[-1] + if (len(all_returns) > 1 + or any(ret.parent != body for ret, _ in returns)): + rn.risk = "render-control-flow" + else: + render_body = None + rn.children = (_lower_expr(render_body, ntext, counter, + events_out, element_name) + if render_body is not None else []) + return [rn] + elif n.type in ("arrow_function", "function_expression"): + rn = make("{" + ntext(n, 60) + "}", risk="render-prop") + return [rn] if rn is not None else [] + rn = make("{" + ntext(n, 80) + "}") + return [rn] if rn is not None else [] + + +def _event_action(value_node, setters, ntext): + expr = value_node + if expr.type == "jsx_expression": + expr = expr.named_children[0] if expr.named_children else None + expr = _unwrap_parens(expr) + if expr is None: + return "" + if expr.type in ("arrow_function", "function_expression"): + body = expr.child_by_field_name("body") + body = _unwrap_parens(body) + if body is not None and body.type == "statement_block": + stmts = body.named_children + if (len(stmts) == 1 and stmts[0].type == "expression_statement" + and stmts[0].named_children + and stmts[0].named_children[0].type == "call_expression"): + body = stmts[0].named_children[0] + else: + body = None + if body is not None and body.type == "call_expression": + callee = body.child_by_field_name("function") + callee_txt = ntext(callee, 60) if callee is not None else "" + if callee_txt in setters: + args = body.child_by_field_name("arguments") + arg = args.named_children[0] if args is not None and args.named_children else None + state = setters[callee_txt] + if arg is None: + arg_txt = "undefined" + elif SECRET_NAME.search(state): + arg_txt = sanitize_string(ntext(arg, 10000), secret_hint=True) + else: + arg_txt = ntext(arg, 60) + return f"set {state}={arg_txt}" + return callee_txt + return ntext(expr, 80) + return ntext(expr, 60) + + +def _extract_events(react, ntext): + setters = react.get("setters", {}) + for element, attr, value, secret in react.pop("_raw_events", []): + action = (sanitize_string(ntext(value, 10000), secret_hint=True) + if secret else _event_action(value, setters, ntext)) + react["events"].append(EventUse(f"{element}.{attr}", action, + value.start_point[0] + 1)) + + +def _secret_fragments(node, setters, ntext) -> list[str]: + """Collect syntax-selected secret values that can reappear in raw RET ops.""" + selected = [] + + def add(value): + if value is None: + return + selected.append(value) + # A capped flow detail can contain only part of a compound expression; + # retain literal descendants as additional safe replacement units. + stack = list(value.named_children) + while stack: + child = stack.pop() + if child.type in {"string", "template_string"}: + selected.append(child) + continue + stack.extend(child.named_children) + + stack = [node] + while stack: + current = stack.pop() + if current.type == "jsx_attribute": + name = current.named_children[0] if current.named_children else None + value = current.named_children[1] if len(current.named_children) > 1 else None + if name is not None and SECRET_NAME.search(ntext(name, 60)): + add(value) + elif current.type == "variable_declarator": + name = current.child_by_field_name("name") + value = current.child_by_field_name("value") + if name is not None and SECRET_NAME.search(ntext(name, 100)): + add(value) + elif current.type in {"assignment_expression", "assignment_pattern", + "object_assignment_pattern"}: + left = current.child_by_field_name("left") + right = current.child_by_field_name("right") + if left is not None and SECRET_NAME.search(ntext(left, 100)): + add(right) + elif current.type == "call_expression": + callee = current.child_by_field_name("function") + callee_txt = ntext(callee, 80) if callee is not None else "" + state = setters.get(callee_txt) + if state and SECRET_NAME.search(state): + arguments = current.child_by_field_name("arguments") + if arguments is not None: + for argument in arguments.named_children: + add(argument) + if callee is not None and callee.type == "member_expression": + prop = callee.child_by_field_name("property") + if prop is not None and ntext(prop, 20) == "map": + arguments = current.child_by_field_name("arguments") + callback = _function_argument(arguments) + if callback is not None: + params = (callback.child_by_field_name("parameter") + or callback.child_by_field_name("parameters")) + if params is not None and SECRET_NAME.search(ntext(params, 100)): + add(callee.child_by_field_name("object")) + stack.extend(current.named_children) + + fragments = [] + for value in selected: + raw = one_line_text(_raw_text(value)) + if raw and raw not in fragments: + fragments.append(raw) + return sorted(fragments, key=lambda value: (-len(value), value)) + + +def _scrub_text(text: str, fragments=()) -> str: + for raw in fragments: + if raw in text: + text = text.replace(raw, sanitize_string(raw, secret_hint=True)) + return _scrub_named_secrets(text) + + +def _scrub_flow_op(op, fragments=()): + detail = op.detail + if op.binds and SECRET_NAME.search(op.binds): + detail = sanitize_string(detail, secret_hint=True) + else: + detail = _scrub_text(detail, fragments) + return replace(op, detail=detail) + + +def _extract_render(node, react, ntext, events_out): + body = node.child_by_field_name("body") + if body is None: + return + counter = {"n": 0, "dropped": False} + jsx_root = None + if body.type != "statement_block": + jsx_root = _unwrap_parens(body) + if not _jsx_bearing(jsx_root): + jsx_root = None + else: + returns = _jsx_returns(body) + if returns: + jsx_root = returns[-1][1] # textually last JSX-bearing return wins + all_returns = _return_nodes(body) + if (len(all_returns) > 1 + or any(ret.parent != body for ret, _ in returns)): + _append_fault(react["faults"], "render-control-flow", + returns[0][0].start_point[0] + 1) + if jsx_root is None: + return + if jsx_root.type in JSX_TYPES: + react["render"] = _lower_jsx(jsx_root, ntext, counter, events_out) + else: + # ternary / `&&` return: _lower_expr lowers both shapes to IF/ELSE nodes + react["render"] = _lower_expr(jsx_root, ntext, counter, events_out, "") + if counter.get("dropped"): + react["render"].append(RenderNode(tag="…", risk="render-truncated", + line=jsx_root.start_point[0] + 1)) + + +def _collect_faults(nodes, faults): + for n in nodes: + if n.risk: + faults.append(f"{n.risk}(L{n.line})") + _collect_faults(n.children, faults) + + +def _render_lines(nodes, level, depth, faults, lines): + for n in nodes: + keep = level >= 3 or n.is_component or n.is_structure + if not keep: + if n.risk: + faults.append(f"{n.risk}(L{n.line})") + _render_lines(n.children, level, depth, faults, lines) + continue + piece = n.tag + if level >= 3 and n.attrs: + piece += f" {n.attrs}" + if n.risk: + piece += f" !FAULT({n.risk})" + faults.append(f"{n.risk}(L{n.line})") + kept_children = _kept(n.children, level) + if (level == 2 and n.is_structure and len(kept_children) == 1 + and not _kept(kept_children[0].children, level)): + child = kept_children[0] + piece += f" > {child.tag}" + if child.risk: + piece += f" !FAULT({child.risk})" + faults.append(f"{child.risk}(L{child.line})") + # The inlined child's descendants are dropped from the tree, but + # their risks must still reach the FAULT-BEFORE footer. + _collect_faults(child.children, faults) + lines.append(" " * depth + piece) + continue + lines.append(" " * depth + piece) + _render_lines(n.children, level, depth + 1, faults, lines) + + +def _kept(nodes, level): + out = [] + for n in nodes: + if level >= 3 or n.is_component or n.is_structure: + out.append(n) + else: + out.extend(_kept(n.children, level)) + return out + + +def _hook_call_op(op, aliases=None, shadowed=None) -> bool: + """True for CALL flow ops whose callee is a hook: already surfaced as + STATE/CTX/REF/HOOK/EFFECT lines, so they must not duplicate as flow ops.""" + if op.op != "CALL": + return False + match = re.match( + r"^\s*([A-Za-z_$][\w$]*(?:\s*\.\s*[A-Za-z_$][\w$]*)*)", + op.detail, + ) + if match is None: + return False + callee = re.sub(r"\s+", "", match.group(1)) + _, _, is_hook = _hook_info(callee, aliases or {}, shadowed or ()) + return is_hook + + +def _react_risk_entries(react, symbol=None) -> list[tuple[str, int]]: + entries = list(react.get("faults", [])) + for hook in react.get("hooks", []): + if hook.risk: + entries.append((hook.risk, hook.line)) + if hook.inherited_risk: + entries.append((hook.inherited_risk, hook.line)) + for op in hook.flow: + if op.risk: + entries.append((op.risk, op.line)) + if symbol is not None: + for op in symbol.flow: + if op.risk: + entries.append((op.risk, op.line)) + stack = list(react.get("render", [])) + while stack: + node = stack.pop() + if node.risk: + entries.append((node.risk, node.line)) + stack.extend(node.children) + return list(dict.fromkeys(entries)) + + +def component_lines(s, level, tier, faults): + handle = s.semantic8 or s.slice8 + head = f"COMPONENT {s.name}({s.signature}) @L{s.span[0]}-{s.span[1]} ^{handle} ~{tier}" + r = s.react + if level == 1: + head_risks = set() + for risk, line in _react_risk_entries(r, s): + if risk not in head_risks: + head += f" !FAULT({risk})" + head_risks.add(risk) + faults.append(f"{risk}(L{line})") + return [head] + lines = [head] + secret_fragments = r.get("secret_fragments", ()) + if r.get("wrapper"): + lines.append(f" WRAP {r['wrapper']}") + if r.get("props"): + lines.append(" PROPS " + ", ".join(r["props"])) + for h in r.get("hooks", []): + hook_risks = [risk for risk in (h.risk, h.inherited_risk) if risk] + tag = "".join(f" !FAULT({risk})" for risk in dict.fromkeys(hook_risks)) + for risk in dict.fromkeys(hook_risks): + faults.append(f"{risk}(L{h.line})") + lines.append(f" {h.kind} {h.detail}{tag}") + if h.kind == "EFFECT" and h.flow: + effect_ops = [_scrub_flow_op(op, secret_fragments) for op in h.flow] + visible_effect_ops = (effect_ops if level >= 3 + else [op for op in effect_ops if op.risk]) + if visible_effect_ops: + lines.extend(flow_lines(s, level, tier, faults, ops=visible_effect_ops)) + for e in r.get("events", []): + lines.append(f" EVENT {e.target} -> {_scrub_text(e.action, secret_fragments)}") + effects = _render_provenanced(s.effects, s.unknown_calls) + if effects: + lines.append(" EFFECTS " + effects) + if level >= 2 and s.flow: + body_ops = [_scrub_flow_op(op, secret_fragments) + for op in s.flow + if not _hook_call_op( + op, r.get("hook_aliases"), r.get("shadowed_hook_aliases"))] + visible_body_ops = (body_ops if level >= 3 + else [op for op in body_ops if op.risk]) + if visible_body_ops: + lines.extend(flow_lines(s, level, tier, faults, ops=visible_body_ops)) + for risk, line in r.get("faults", []): + lines.append(f" !FAULT({risk}) @L{line}") + faults.append(f"{risk}(L{line})") + if r.get("render"): + lines.append(" RENDER") + _render_lines(r["render"], level, 2, faults, lines) + return lines + + +def _inside_class(node) -> bool: + parent = node.parent + while parent is not None: + if parent.type in {"class", "class_declaration", "abstract_class_declaration"}: + return True + parent = parent.parent + return False + + +def lower_components(fn_nodes, ntext, flow_fn) -> bool: + upgraded = False + hook_aliases = _react_hook_aliases(fn_nodes, ntext) + for sym, node in fn_nodes: + short = sym.name.split(".")[-1] + if (node.type not in COMPONENT_FN_TYPES or _inside_class(node) + or not COMPONENT_NAME_RE.match(short) + or not _returns_jsx(node)): + continue + sym.kind = "component" + shadowed_hook_aliases = _shadowed_hook_aliases(node, hook_aliases) + sym.react = { + "wrapper": sym.decorators[0] if sym.decorators else "", + "props": _extract_props(node, ntext), + "hooks": [], + "events": [], + "render": [], + "faults": [], + "hook_aliases": hook_aliases, + "shadowed_hook_aliases": tuple(sorted(shadowed_hook_aliases)), + } + body = node.child_by_field_name("body") + if body is not None and body.type == "statement_block": + _extract_hooks(body, sym.react, ntext, flow_fn, + hook_aliases, shadowed_hook_aliases) + else: + sym.react["setters"] = {} + raw_events = [] + _extract_render(node, sym.react, ntext, raw_events) + sym.react["_raw_events"] = raw_events + _extract_events(sym.react, ntext) + sym.react["secret_fragments"] = _secret_fragments( + node, sym.react.get("setters", {}), ntext) + upgraded = True + return upgraded diff --git a/tests/test_cache_integration.py b/tests/test_cache_integration.py index d777ff6..4256df8 100644 --- a/tests/test_cache_integration.py +++ b/tests/test_cache_integration.py @@ -12,6 +12,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "skills" / "kern" / "scripts")) import kern_cache # noqa: E402 import kern_compile # noqa: E402 +import kern_react # noqa: E402 BIG_PY = '"""Doc."""\n\nimport json\n\n' + "\n\n".join( f'def fn_{i}(path, n):\n' @@ -181,6 +182,58 @@ def test_parser_capability_version_change_invalidates_cache(self): manifest["files"]["mod.js"]["ir_compiler_fingerprint"], ) + def test_react_adapter_content_change_invalidates_tsjs_cache(self): + if not kern_compile.tsjs_available(tsx=True): + self.skipTest("tsx grammar not installed") + self.config["min_ir_tokens"] = 1 + source = "export const Card = ({ title }) =>
{title}
;\n" + (self.root / "card.tsx").write_text(source) + adapter = self.root / "kern_react.py" + adapter.write_text("# adapter revision one\n") + + with mock.patch.object(kern_react, "__file__", str(adapter)): + first = self.ensure("card.tsx") + manifest = json.loads(self.paths["manifest"].read_text()) + first_fingerprint = manifest["files"]["card.tsx"]["ir_compiler_fingerprint"] + self.assertFalse(first["cache_hit"]) + self.assertEqual(first["mode"], "structured:tree-sitter+react") + self.assertTrue(self.ensure("card.tsx")["cache_hit"]) + + adapter.write_text("# adapter revision two\n") + changed = self.ensure("card.tsx") + manifest = json.loads(self.paths["manifest"].read_text()) + + self.assertFalse(changed["cache_hit"]) + self.assertNotEqual( + first_fingerprint, + manifest["files"]["card.tsx"]["ir_compiler_fingerprint"], + ) + + def test_react_adapter_fingerprint_tolerates_missing_and_unavailable_file(self): + source = self.root / "card.tsx" + source.write_text("export const Card = () =>
;\n") + adapter = self.root / "missing-kern-react.py" + + with mock.patch.object(kern_react, "__file__", str(adapter)): + missing = kern_cache.compiler_fingerprint( + source, self.config, "L2", "structured:tree-sitter+react" + ) + + adapter.write_text("# unreadable adapter\n") + original_sha256_file = kern_cache.sha256_file + + def reject_adapter(path): + if Path(path) == adapter: + raise PermissionError("adapter source is unreadable") + return original_sha256_file(path) + + with mock.patch.object(kern_cache, "sha256_file", side_effect=reject_adapter): + unavailable = kern_cache.compiler_fingerprint( + source, self.config, "L2", "structured:tree-sitter+react" + ) + + self.assertNotEqual(missing, unavailable) + def test_pre_fingerprint_manifest_is_proactively_invalidated(self): self.ensure("big.py") manifest = json.loads(self.paths["manifest"].read_text()) diff --git a/tests/test_react.py b/tests/test_react.py new file mode 100644 index 0000000..77cb3e6 --- /dev/null +++ b/tests/test_react.py @@ -0,0 +1,546 @@ +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "skills" / "kern" / "scripts")) +import kern_compile # noqa: E402 + +TSX_SAMPLE = ''' +import { useState, useEffect } from "react"; +import { Card, Avatar, UserDetails } from "./ui"; + +export function UserCard({ user, onClose = noop }) { + const [open, setOpen] = useState(false); + + useEffect(() => { + analytics.track("view_user", user.id); + }, [user.id]); + + return ( + setOpen(true)}> + + {user.name} + {open && } + + ); +} + +export function formatName(name: string): string { + return name.trim(); +} +''' + + +class TestGrammarRouting(unittest.TestCase): + @unittest.skipUnless(kern_compile.tsjs_available(tsx=True), "tsx grammar not installed") + def test_tsx_grammar_parses_jsx_clean(self): + mod = kern_compile.parse_tsjs(TSX_SAMPLE, tsx=True) + self.assertEqual(mod.parse_error, "") + self.assertEqual(mod.lang, "typescript") + + @unittest.skipUnless(kern_compile.tsjs_available(typescript=True), "typescript grammar not installed") + def test_plain_ts_grammar_chokes_on_jsx(self): + # Documents the routed-around limitation: TS grammar has no JSX. + mod = kern_compile.parse_tsjs(TSX_SAMPLE, typescript=True) + self.assertNotEqual(mod.parse_error, "") + + @unittest.skipUnless(kern_compile.tsjs_available(), "javascript grammar not installed") + def test_js_grammar_still_default(self): + mod = kern_compile.parse_tsjs("function f() { return 1; }\n") + self.assertEqual(mod.lang, "javascript") + + +@unittest.skipUnless(kern_compile.tsjs_available(tsx=True), "tsx grammar not installed") +class TestComponentDetection(unittest.TestCase): + def parse(self, src, **kwargs): + kwargs.setdefault("tsx", True) + return kern_compile.parse_tsjs(src, **kwargs) + + def sym(self, mod, name): + return next(s for s in mod.symbols if s.name == name) + + def test_capitalized_jsx_function_is_component(self): + mod = self.parse(TSX_SAMPLE) + s = self.sym(mod, "UserCard") + self.assertEqual(s.kind, "component") + self.assertEqual(mod.frontend, "tree-sitter+react") + + def test_props_extracted(self): + mod = self.parse(TSX_SAMPLE) + self.assertEqual(self.sym(mod, "UserCard").react["props"], + ["user", "onClose=noop"]) + + def test_capitalized_without_jsx_stays_function(self): + mod = self.parse("export function Parse(x) { return x + 1; }\n") + self.assertEqual(self.sym(mod, "Parse").kind, "function") + + def test_lowercase_with_jsx_stays_function(self): + mod = self.parse("function helper() { return
; }\n") + self.assertEqual(self.sym(mod, "helper").kind, "function") + + def test_arrow_component_detected(self): + mod = self.parse("export const Badge = ({ label }) => {label};\n") + self.assertEqual(self.sym(mod, "Badge").kind, "component") + + def test_memo_wrapper_unwrapped(self): + mod = self.parse("import { memo } from 'react';\n" + "const Row = memo(({ id }) =>
  • {id}
  • );\n") + s = self.sym(mod, "Row") + self.assertEqual(s.kind, "component") + self.assertEqual(s.react["wrapper"], "memo") + + def test_memo_with_comparator_identifier_not_hijacked(self): + src = ("const Row = ({ id }) =>
  • {id}
  • ;\n" + "const MemoRow = memo(Row, (a, b) => a.id === b.id);\n") + mod = self.parse(src) + memo_syms = [s for s in mod.symbols if s.name == "MemoRow"] + self.assertEqual(len(memo_syms), 1) + self.assertEqual(memo_syms[0].kind, "const") # falls to const fallback + + def test_memo_inline_with_comparator_uses_first_arg(self): + src = "const Row = memo(({ id }) =>
  • {id}
  • , (a, b) => a.id === b.id);\n" + mod = self.parse(src) + s = self.sym(mod, "Row") + self.assertEqual(s.kind, "component") + self.assertEqual(s.react["wrapper"], "memo") + self.assertEqual(s.react["props"], ["id"]) + + def test_non_react_file_untouched(self): + mod = self.parse("export function parse(raw) { return Number(raw); }\n", + tsx=False) + self.assertEqual(mod.frontend, "tree-sitter") + self.assertEqual(self.sym(mod, "parse").kind, "function") + + def test_ternary_return_is_component(self): + mod = self.parse("function Card({ loading }) {\n" + " return loading ? : ;\n}\n") + self.assertEqual(self.sym(mod, "Card").kind, "component") + + def test_logical_and_return_is_component(self): + mod = self.parse("const Hint = ({ ok }) => ok && hi;\n") + self.assertEqual(self.sym(mod, "Hint").kind, "component") + + def test_nested_return_jsx_in_inner_fn_not_component(self): + src = ("function Outer() {\n" + " const inner = () =>
    ;\n" + " return 42;\n" + "}\n") + mod = self.parse(src) + self.assertEqual(self.sym(mod, "Outer").kind, "function") + + +@unittest.skipUnless(kern_compile.tsjs_available(tsx=True), "tsx grammar not installed") +class TestHooks(unittest.TestCase): + def component(self, src): + mod = kern_compile.parse_tsjs(src, tsx=True) + return next(s for s in mod.symbols if s.kind == "component") + + def hooks(self, src): + return self.component(src).react["hooks"] + + def test_usestate_setter_and_init(self): + s = self.component(TSX_SAMPLE) + h = [x for x in s.react["hooks"] if x.kind == "STATE"] + self.assertEqual(h[0].detail, "open=false") + self.assertEqual(s.react["setters"], {"setOpen": "open"}) + + def test_effect_deps_verbatim(self): + h = [x for x in self.hooks(TSX_SAMPLE) if x.kind == "EFFECT"] + self.assertEqual(h[0].detail, "deps=[user.id]") + self.assertTrue(h[0].flow) # body captured for L3 + + def test_effect_missing_deps(self): + src = ("function T() {\n useEffect(() => { tick(); });\n" + " return
    ;\n}\n") + h = [x for x in self.hooks(src) if x.kind == "EFFECT"] + self.assertEqual(h[0].detail, "deps=EVERY-RENDER") + + def test_reducer_context_ref_custom(self): + src = ("function T() {\n" + " const [state, dispatch] = useReducer(reducer, init);\n" + " const theme = useContext(ThemeContext);\n" + " const inputRef = useRef(null);\n" + " const data = useUserData(id);\n" + " return
    ;\n}\n") + kinds = [(h.kind, h.detail) for h in self.hooks(src)] + self.assertEqual(kinds, [ + ("STATE", "[state, dispatch]=useReducer(reducer, init)"), + ("CTX", "theme=useContext(ThemeContext)"), + ("REF", "inputRef"), + ("HOOK", "data=useUserData(id)"), + ]) + + def test_aliased_hook_faulted(self): + src = ("import * as R from 'react';\n" + "function T() {\n const [a, setA] = R.useState(0);\n" + " return
    ;\n}\n") + h = self.hooks(src)[0] + self.assertEqual(h.risk, "aliased-hook") + + def test_conditional_hook_faulted(self): + src = ("function T({ on }) {\n" + " if (on) { useEffect(() => {}); }\n" + " return
    ;\n}\n") + faults = self.component(src).react["faults"] + self.assertIn("conditional-hook", [f[0] for f in faults]) + + def test_logical_and_guarded_hook_faulted(self): + src = ("function T({ on }) {\n" + " on && useEffect(() => {});\n" + " return
    ;\n}\n") + faults = self.component(src).react["faults"] + self.assertIn("conditional-hook", [f[0] for f in faults]) + + def test_paren_wrapped_guarded_hook_faulted(self): + src = ("function T({ on }) {\n" + " (on && useHook());\n" + " return
    ;\n}\n") + faults = self.component(src).react["faults"] + self.assertIn("conditional-hook", [f[0] for f in faults]) + + def test_ternary_guarded_hook_faulted(self): + src = ("function T({ on }) {\n" + " on ? useState(0) : null;\n" + " return
    ;\n}\n") + faults = self.component(src).react["faults"] + self.assertIn("conditional-hook", [f[0] for f in faults]) + + def test_plain_toplevel_call_not_faulted(self): + src = ("function T() {\n" + " analytics.track(useContextValue());\n" + " return
    ;\n}\n") + faults = self.component(src).react["faults"] + self.assertEqual(faults, []) + + +@unittest.skipUnless(kern_compile.tsjs_available(tsx=True), "tsx grammar not installed") +class TestRenderTree(unittest.TestCase): + def render(self, src): + mod = kern_compile.parse_tsjs(src, tsx=True) + comp = next(s for s in mod.symbols if s.kind == "component") + return comp.react["render"] + + def flat(self, nodes, depth=0): + out = [] + for n in nodes: + out.append((depth, n.tag, n.risk)) + out.extend(self.flat(n.children, depth + 1)) + return out + + def test_hierarchy_and_conditional(self): + tags = self.flat(self.render(TSX_SAMPLE)) + self.assertIn((0, "Card", ""), tags) + self.assertIn((1, "Avatar", ""), tags) + self.assertIn((1, "span", ""), tags) + self.assertIn((1, "IF open", ""), tags) + self.assertIn((2, "UserDetails", ""), tags) + + def test_component_flag_and_attrs(self): + nodes = self.render(TSX_SAMPLE) + card = nodes[0] + self.assertTrue(card.is_component) + avatar = next(c for c in card.children if c.tag == "Avatar") + self.assertEqual(avatar.attrs, "src={user.avatar}") + + def test_map_becomes_for(self): + src = ("function L({ items }) {\n" + " return
      {items.map(item => )}
    ;\n}\n") + tags = [t for _, t, _ in self.flat(self.render(src))] + self.assertIn("FOR item in items", tags) + self.assertIn("Row", tags) + + def test_ternary_if_else(self): + src = ("function T({ ok }) {\n" + " return
    {ok ? : }
    ;\n}\n") + tags = [t for _, t, _ in self.flat(self.render(src))] + self.assertIn("IF ok", tags) + self.assertIn("ELSE", tags) + + def test_ternary_return_lowered_to_if_else(self): + src = ("function Card({ loading }) {\n" + " return loading ? : ;\n}\n") + tags = [t for _, t, _ in self.flat(self.render(src))] + self.assertIn("IF loading", tags) + self.assertIn("ELSE", tags) + self.assertIn("Spinner", tags) + self.assertIn("Content", tags) + + def test_logical_and_return_lowered_to_if(self): + src = "const Hint = ({ ok }) => ok && hi;\n" + tags = [t for _, t, _ in self.flat(self.render(src))] + self.assertIn("IF ok", tags) + self.assertIn("b", tags) + + def test_dynamic_component_faulted(self): + src = ("function T() {\n return ;\n}\n") + flat = self.flat(self.render(src)) + self.assertIn("dynamic-component", [r for _, _, r in flat]) + + def test_spread_sole_prop_source_faulted(self): + src = ("function T({ rest }) {\n return ;\n}\n") + node = self.render(src)[0] + self.assertEqual(node.risk, "spread-props") + self.assertIn("...rest", node.attrs) + + def test_spread_with_named_attrs_not_faulted(self): + src = ("function T({ rest }) {\n return ;\n}\n") + self.assertEqual(self.render(src)[0].risk, "") + + def test_render_prop_faulted(self): + src = ("function T() {\n" + " return {item => };\n}\n") + flat = self.flat(self.render(src)) + self.assertIn("render-prop", [r for _, _, r in flat]) + + def test_fragment(self): + src = "function T() {\n return <>;\n}\n" + tags = [t for _, t, _ in self.flat(self.render(src))] + self.assertIn("A", tags) + self.assertIn("B", tags) + + def test_guard_clause_last_return_wins(self): + src = ("function Card({ loading }) {\n" + " if (loading) { return ; }\n" + " return ;\n}\n") + tags = [t for _, t, _ in self.flat(self.render(src))] + self.assertEqual(tags, ["Content"]) + + def test_two_guards_last_return_wins(self): + src = ("function T({ a, b }) {\n" + " if (a) return ;\n" + " if (b) return ;\n" + " return ;\n}\n") + tags = [t for _, t, _ in self.flat(self.render(src))] + self.assertEqual(tags, ["C"]) + + def test_exact_budget_no_false_truncation(self): + # 1 (outer div) + 199 self-closing children == RENDER_BUDGET (200) + # exactly, with nothing dropped -- must NOT be flagged as truncated. + children = "" * 199 + src = f"function T() {{\n return
    {children}
    ;\n}}\n" + risks = [r for _, _, r in self.flat(self.render(src))] + self.assertNotIn("render-truncated", risks) + + def test_overflow_truncation_faulted(self): + children = "".join(f"{{x{i}}}" for i in range(120)) + src = f"function T() {{\n return
    {children}
    ;\n}}\n" + risks = [r for _, _, r in self.flat(self.render(src))] + self.assertIn("render-truncated", risks) + + def test_dynamic_component_with_spread_keeps_both_faults(self): + src = "function T({ p }) {\n return ;\n}\n" + risks = [r for _, _, r in self.flat(self.render(src))] + self.assertIn("dynamic-component+spread-props", risks) + + +@unittest.skipUnless(kern_compile.tsjs_available(tsx=True), "tsx grammar not installed") +class TestEvents(unittest.TestCase): + def events(self, src): + mod = kern_compile.parse_tsjs(src, tsx=True) + comp = next(s for s in mod.symbols if s.kind == "component") + return comp.react["events"] + + def test_setter_arrow_lowered(self): + ev = self.events(TSX_SAMPLE) + self.assertEqual([(e.target, e.action) for e in ev], + [("Card.onClick", "set open=true")]) + + def test_handler_reference(self): + src = ("function T({ onSave }) {\n" + " return ;\n}\n") + ev = self.events(src) + self.assertEqual((ev[0].target, ev[0].action), ("button.onClick", "onSave")) + + def test_non_setter_arrow_uses_callee(self): + src = ("function T() {\n" + " return
    analytics.track('x')} />;\n}\n") + ev = self.events(src) + self.assertEqual(ev[0].action, "analytics.track") + + def test_raw_events_cleaned_up(self): + mod = kern_compile.parse_tsjs(TSX_SAMPLE, tsx=True) + comp = next(s for s in mod.symbols if s.kind == "component") + self.assertNotIn("_raw_events", comp.react) + + def test_guarded_setter_not_lowered_to_unconditional_set(self): + src = ("function T() {\n" + " const [open, setOpen] = useState(false);\n" + " return { if (open) return; setOpen(true); }} />;\n}\n") + ev = self.events(src) + self.assertNotEqual(ev[0].action, "set open=true") + self.assertIn("if", ev[0].action) # guard remains visible in the fallback text + + def test_single_statement_setter_still_lowered(self): + src = ("function T() {\n" + " const [open, setOpen] = useState(false);\n" + " return { setOpen(true); }} />;\n}\n") + ev = self.events(src) + self.assertEqual(ev[0].action, "set open=true") + + +@unittest.skipUnless(kern_compile.tsjs_available(tsx=True), "tsx grammar not installed") +class TestEmit(unittest.TestCase): + def il(self, tier, src=None): + mod = kern_compile.parse_tsjs(src or TSX_SAMPLE, tsx=True) + return kern_compile.emit_il(mod, "app/UserCard.tsx", "a" * 64, "none", tier) + + def test_l1_head_only(self): + il = self.il("L1") + self.assertIn("COMPONENT UserCard({ user, onClose = noop }) @L", il) + self.assertNotIn("STATE", il) + self.assertNotIn("RENDER", il) + + def test_l2_heads_and_collapsed_render(self): + il = self.il("L2") + self.assertIn(" PROPS user, onClose=noop", il) + self.assertIn(" STATE open=false", il) + self.assertIn(" EFFECT deps=[user.id]", il) + self.assertIn(" EVENT Card.onClick -> set open=true", il) + self.assertIn(" RENDER", il) + self.assertIn("Card", il) + self.assertIn("IF open > UserDetails", il) # single kept child inlined + self.assertNotIn("span", il) # host element collapsed at L2 + self.assertNotIn("src={user.avatar}", il) # attrs dropped at L2 + + def test_l3_full_render_and_effect_body(self): + il = self.il("L3") + self.assertIn("span", il) + self.assertIn("src={user.avatar}", il) + self.assertIn("CALL analytics.track", il) # effect body flow + + def test_frontend_header_tag(self): + self.assertIn("frontend=tree-sitter+react", self.il("L2")) + + def test_fault_footer(self): + src = "function T() {\n return ;\n}\n" + il = self.il("L2", src) + self.assertIn("!FAULT(dynamic-component)", il) + self.assertIn("dynamic-component(L", il) # FAULT-BEFORE footer + + def test_plain_function_in_same_file_unchanged(self): + il = self.il("L2") + self.assertIn("F formatName(name: string) -> string", il) + + def test_l2_collapsed_node_fault_reaches_footer(self): + src = ("function T({ props }) {\n" + " return
    hi
    ;\n}\n") + il = self.il("L2", src) + self.assertIn("spread-props(L", il) # footer net holds at L2 + + def test_l2_inline_when_child_has_only_host_children(self): + src = ("function T({ open, user }) {\n" + " return
    {open && x}
    ;\n}\n") + il = self.il("L2", src) + self.assertIn("IF open > UserDetails", il) + + def test_l2_inlined_child_descendant_fault_reaches_footer(self): + src = ("function T({ open, rest }) {\n" + " return
    {open &&
    }
    ;\n}\n") + il = self.il("L2", src) + self.assertIn("spread-props(L", il) + + def test_l3_component_body_flow_visible(self): + src = ("function Card({ items, loading }) {\n" + " const total = computeTotal(items);\n" + " if (loading) { return ; }\n" + " return
    {total}
    ;\n}\n") + il = self.il("L3", src) + self.assertIn("computeTotal", il) + self.assertIn("IF loading", il) + self.assertIn("Spinner", il) # guard render visible via RET flow op + + def test_l2_component_effects_line(self): + # console.log classifies into the console effect class; useEffect + # stays unknown, so both EFFECTS and unknown-calls= must render. + src = ("function T() {\n" + " useEffect(() => { fetch('/api'); }, []);\n" + " console.log('x');\n" + " return
    ;\n}\n") + il = self.il("L2", src) + self.assertIn("EFFECTS", il) + self.assertIn("unknown-calls=", il) + + def test_l3_hook_calls_not_duplicated_as_flow_ops(self): + # useState appears once as STATE, not again as a CALL flow op. + src = ("function T() {\n" + " const [open, setOpen] = useState(false);\n" + " return
    ;\n}\n") + il = self.il("L3", src) + self.assertEqual(il.count("useState"), 0) # STATE open=false covers it + self.assertIn("STATE open=false", il) + + +@unittest.skipUnless(kern_compile.tsjs_available(tsx=True), "tsx grammar not installed") +class TestRedaction(unittest.TestCase): + def _il(self, src): + mod = kern_compile.parse_tsjs(src, tsx=True) + return kern_compile.emit_il(mod, "app/T.tsx", "b" * 64, "none", "L3") + + def test_prop_default_secret_redacted(self): + il = self._il('function Login({ apiToken = "hunter2-plain" }) {\n return
    ;\n}\n') + self.assertNotIn("hunter2-plain", il) + self.assertIn("REDACTED", il) + + def test_state_secret_redacted(self): + il = self._il('function T() {\n const [password, setPassword] = useState("letmein123");\n return
    ;\n}\n') + self.assertNotIn("letmein123", il) + + def test_jsx_attr_secret_redacted(self): + il = self._il('function T() {\n return ;\n}\n') + self.assertNotIn("hunter2abc", il) + + def test_custom_hook_bound_secret_redacted(self): + il = self._il('function T() {\n const authToken = useToken("tok-abc-plain");\n return
    ;\n}\n') + self.assertNotIn("tok-abc-plain", il) + + def test_non_secret_names_untouched(self): + il = self._il('function T({ label = "hello" }) {\n' + ' const [open, setOpen] = useState("wide");\n' + ' return ;\n}\n') + self.assertIn('label = "hello"', il) # signature verbatim + self.assertIn("open=\"wide\"", il) + self.assertIn('placeholder="type here"', il) + self.assertNotIn("REDACTED", il) + + def test_event_setter_secret_redacted(self): + # data-pad pushes the RET flow op's 100-char raw-text capture past the + # onClick handler, isolating this test to the EVENT-line surface + # (a separate, pre-existing gap in RET's raw-JSX text capture for + # call-form literals is out of scope for this fix). + pad = "x" * 80 + il = self._il('function T() {\n' + ' const [apiToken, setApiToken] = useState("");\n' + f' return setApiToken("hunter2-literal")}} />;\n}}\n') + self.assertNotIn("hunter2-literal", il) + + def test_event_fallback_secret_redacted(self): + il = self._il('function T() {\n' + ' return { const password = "hunter2-ev"; save(password); }} />;\n}\n') + self.assertNotIn("hunter2-ev", il) + + def test_effect_body_secret_redacted(self): + il = self._il('function T() {\n' + ' useEffect(() => { if (window.token = "hunter2-eff") { save(); } }, []);\n' + ' return
    ;\n}\n') + self.assertNotIn("hunter2-eff", il) + + +@unittest.skipUnless(kern_compile.tsjs_available(), "javascript grammar not installed") +class TestNoOpOnPlainCode(unittest.TestCase): + PLAIN = ('import { readFile } from "fs/promises";\n\n' + "export async function load(url) {\n" + " const data = await readFile(url);\n" + " if (!data) {\n throw new Error(url);\n }\n" + " return data.toString();\n}\n") + + def test_component_kind_absent_and_frontend_plain(self): + mod = kern_compile.parse_tsjs(self.PLAIN) + self.assertEqual(mod.frontend, "tree-sitter") + self.assertFalse(any(s.kind == "component" for s in mod.symbols)) + il = kern_compile.emit_il(mod, "src/load.js", "c" * 64, "none", "L2") + self.assertNotIn("COMPONENT", il) + self.assertIn("frontend=tree-sitter tier=L2", il) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_react_adversarial.py b/tests/test_react_adversarial.py new file mode 100644 index 0000000..0cd4ec0 --- /dev/null +++ b/tests/test_react_adversarial.py @@ -0,0 +1,393 @@ +import sys +import unittest +from pathlib import Path + + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "skills" / "kern" / "scripts")) +import kern_compile # noqa: E402 + + +@unittest.skipUnless(kern_compile.tsjs_available(tsx=True), "tsx grammar not installed") +class TestReactAdversarial(unittest.TestCase): + def parse(self, source): + return kern_compile.parse_tsjs(source, tsx=True) + + def il(self, source, tier="L3"): + module = self.parse(source) + return kern_compile.emit_il( + module, "app/T.tsx", "c" * 64, "none", tier, + ) + + def test_compound_and_nested_prop_defaults_are_structurally_redacted(self): + source = ( + 'function Login({ config: { apiToken = String("prop-secret"), ' + 'label = "visible" }, password: local = make("nested-secret") }) {' + ' return
    ; }' + ) + for tier in ("L1", "L2", "L3"): + rendered = self.il(source, tier) + self.assertNotIn("prop-secret", rendered) + self.assertNotIn("nested-secret", rendered) + self.assertIn('label = "visible"', self.il(source)) + + def test_compound_default_does_not_reintroduce_secret_literal_type(self): + source = ( + 'function Login({ apiToken = String("default-secret"), password }:' + ' { apiToken?: string; password: "type-secret" }) {' + ' return
    ; }' + ) + rendered = self.il(source, "L1") + self.assertNotIn("default-secret", rendered) + self.assertNotIn("type-secret", rendered) + + def test_aliased_prop_preserves_generic_secret_redaction(self): + source = ( + 'function Login({ label: local = "sk-abcdefghijklmnop", ' + 'value: other = getSecret("plain-secret") }) {' + ' return
    ; }' + ) + rendered = self.il(source) + self.assertNotIn("sk-abcdefghijklmnop", rendered) + self.assertNotIn("plain-secret", rendered) + + def test_secret_event_attr_and_nested_setter_argument_do_not_leak(self): + source = ( + 'function T(){ const [password, setPassword] = useState("");' + ' return setPassword(make("setter-secret"))}/>; }' + ) + rendered = self.il(source) + self.assertNotIn("event-secret", rendered) + self.assertNotIn("setter-secret", rendered) + self.assertIn("EVENT Input.onToken -> set password= { const password = identity("effect-secret");' + ' save(password); }, []); return ; }' + ) + rendered = self.il(source) + self.assertNotIn("component-secret", rendered) + self.assertNotIn("effect-secret", rendered) + self.assertIn("-> apiToken", rendered) + self.assertIn("-> password", rendered) + + def test_l1_keeps_react_fault_in_head_and_footer(self): + rendered = self.il("function T(){ return ; }", "L1") + self.assertIn("COMPONENT T()", rendered) + self.assertIn("!FAULT(dynamic-component)", rendered) + self.assertIn("dynamic-component(L1)", rendered) + + def test_l2_keeps_inherited_flow_risk(self): + source = "function T({pattern}){ const re = RegExp(pattern); return ; }" + rendered = self.il(source, "L2") + self.assertIn("CALL !FAULT(regex)", rendered) + self.assertIn("regex(L1)", rendered) + + def test_l2_keeps_effect_risk_without_emitting_plain_body_flow(self): + source = ( + "function T({value}){ useEffect(() => value.match(/a+/), []);" + " return ; }" + ) + rendered = self.il(source, "L2") + self.assertIn("CALL !FAULT(regex)", rendered) + self.assertIn("regex(L1)", rendered) + self.assertNotIn("\n RET", rendered) + + def test_hook_argument_risks_survive_all_tiers(self): + source = ( + "function T({pattern}){ const [re] = useState(RegExp(pattern));" + " return ; }" + ) + for tier in ("L1", "L2", "L3"): + rendered = self.il(source, tier) + self.assertIn("!FAULT(regex)", rendered) + self.assertIn("regex(L1)", rendered) + + def test_l1_keeps_effect_body_risk(self): + source = ( + "function T({pattern}){ useEffect(() => RegExp(pattern), []);" + " return ; }" + ) + rendered = self.il(source, "L1") + self.assertIn("!FAULT(regex)", rendered) + self.assertIn("regex(L1)", rendered) + + def test_conditional_hook_in_declaration_initializer_is_faulted(self): + source = ( + "function T({ready}){ const state = ready ? useState(0) : null;" + " return ; }" + ) + rendered = self.il(source, "L1") + self.assertIn("!FAULT(conditional-hook)", rendered) + self.assertIn("conditional-hook(L1)", rendered) + + def test_nested_conditional_hook_shapes_are_faulted(self): + cases = ( + "let state; state = ready ? useState(0) : null;", + "const state = identity(ready ? useState(0) : null);", + "const view =
    {ready && useState(0)}
    ;", + ) + for body in cases: + with self.subTest(body=body): + source = f"function T({{ready}}){{ {body} return ; }}" + rendered = self.il(source, "L1") + self.assertIn("!FAULT(conditional-hook)", rendered) + + def test_renamed_react_hook_imports_are_lowered_and_faulted(self): + source = ( + 'import { useState as useS, useEffect as useE } from "react";' + " function T(){ const [value, setValue] = useS(0);" + " useE(() => tick(), []); return ; }" + ) + rendered = self.il(source) + self.assertIn("STATE value=0 !FAULT(aliased-hook)", rendered) + self.assertIn("EFFECT deps=[] !FAULT(aliased-hook)", rendered) + self.assertIn("CALL tick()", rendered) + self.assertEqual(rendered.count("CALL useS"), 0) + + def test_renamed_hook_alias_does_not_capture_member_or_shadowed_binding(self): + source = ( + 'import { useState as useS } from "react";' + " function T({useS}){ const a = obj.useS(0);" + " const b = useS(1); return ; }" + ) + rendered = self.il(source) + self.assertNotIn("STATE a=", rendered) + self.assertNotIn("STATE b=", rendered) + self.assertIn("CALL obj.useS(0)", rendered) + self.assertIn("CALL useS(1)", rendered) + + def test_generic_renamed_hook_is_not_duplicated_as_flow(self): + source = ( + 'import { useState as useS } from "react";' + " function T(){ const [value] = useS(0); return ; }" + ) + rendered = self.il(source) + self.assertIn("STATE value=0", rendered) + self.assertNotIn("CALL useS", rendered) + + def test_function_typed_generic_hook_is_not_duplicated_as_flow(self): + rendered = self.il( + "function T(){ const cb = useCallback<() => void>(() => {});" + " return ; }" + ) + self.assertIn("HOOK cb=useCallback<() => void>", rendered) + self.assertNotIn("CALL useCallback<() => void>", rendered) + + def test_logical_assignment_and_optional_hooks_are_conditional(self): + for operator in ("&&=", "||=", "??="): + with self.subTest(operator=operator): + rendered = self.il( + f"function T({{ready}}){{ ready {operator} useState(0);" + " return ; }", + "L1", + ) + self.assertIn("!FAULT(conditional-hook)", rendered) + rendered = self.il("function T(){ useMaybe?.(0); return ; }", "L1") + self.assertIn("!FAULT(conditional-hook)", rendered) + + def test_awaited_hook_call_is_not_silently_suppressed(self): + rendered = self.il( + "async function T(){ await useData(); return ; }", + "L3", + ) + self.assertIn("CALL await useData()", rendered) + + def test_unguarded_conditional_operands_are_not_faulted(self): + sources = ( + "function T(){ const value = useReady() && true; return ; }", + "function T(){ const value = useReady() ? 1 : 2; return ; }", + ("function T({registry, maybe}){ const value = " + "registry[maybe?.name].useState(0); return ; }"), + ) + for source in sources: + with self.subTest(source=source): + self.assertNotIn("conditional-hook", self.il(source, "L1")) + + def test_parentheses_terminate_optional_chain_short_circuiting(self): + source = "function T(){ (React?.hooks).useState(0); return ; }" + self.assertNotIn("conditional-hook", self.il(source, "L1")) + + def test_expression_bodied_effect_callback_is_lowered(self): + source = ( + 'function T(){ useEffect(() => analytics.track("view"), []);' + " return ; }" + ) + self.assertIn('CALL analytics.track("view")', self.il(source)) + + def test_expression_bodied_cleanup_callback_is_lowered(self): + source = ( + "function T(){ useEffect(() => () => cleanup(), []);" + " return ; }" + ) + self.assertIn("CALL cleanup()", self.il(source)) + + def test_wrapped_effect_callback_is_lowered(self): + source = ( + "function T(){ useEffect((() => tick()) as EffectCallback, []);" + " return ; }" + ) + self.assertIn("CALL tick()", self.il(source)) + + def test_block_bodied_map_callback_return_is_lowered(self): + source = ( + "function T({items}){ return
    {items.map(item => {" + " return ; })}
    ; }" + ) + rendered = self.il(source) + self.assertIn("FOR item in items", rendered) + self.assertIn("Row key={item.id}", rendered) + + def test_guarded_block_map_is_faulted(self): + source = ( + "function T({items}){ return
    {items.map(item => {" + " if (!item) return null; return ; })}
    ; }" + ) + rendered = self.il(source, "L2") + self.assertIn("FOR item in items", rendered) + self.assertIn("> Row", rendered) + self.assertIn("!FAULT(render-control-flow)", rendered) + + def test_secret_named_map_binding_redacts_receiver_and_raw_flow(self): + source = ( + 'function T(){ return
    {["map-secret"].map(' + "password => )}
    ; }" + ) + for tier in ("L2", "L3"): + rendered = self.il(source, tier) + self.assertNotIn("map-secret", rendered) + self.assertIn("FOR password in {["quartz-velvet-7319"].map(' + "((password => ) as Mapper))}
    ; }" + ) + rendered = self.il(source, "L3") + self.assertNotIn("quartz-velvet-7319", rendered) + self.assertIn("FOR password in {items.map(" + "renderItem, () => )}
    ; }" + ) + rendered = self.il(source, "L3") + self.assertNotIn("FOR _ in items", rendered) + self.assertNotIn("\n Fake", rendered) + + def test_long_structural_secret_values_are_redacted_before_text_cap(self): + secret = "quartzvelvet" * 20 + sources = ( + f'function T(){{ return ; }}', + (f'function T(){{ return
    {{["{secret}"].map(' + "apiToken => )}
    ; }"), + ) + for source in sources: + with self.subTest(source=source[:40]): + self.assertNotIn(secret[:64], self.il(source, "L3")) + + def test_ts_as_and_satisfies_wrappers_are_components(self): + module = self.parse( + "const A = () => ( as JSX.Element);" + " const B = () => satisfies ReactNode;" + ) + kinds = {symbol.name: symbol.kind for symbol in module.symbols} + self.assertEqual(kinds["A"], "component") + self.assertEqual(kinds["B"], "component") + + def test_ts_non_null_wrapper_is_component(self): + module = self.parse("const A = () => (!);") + self.assertEqual(next(symbol for symbol in module.symbols if symbol.name == "A").kind, + "component") + + def test_class_method_and_nested_generator_do_not_upgrade(self): + source = ( + "class View { Card(){ return ; } }" + " function Outer(){ const Inner = function*(){ return ; };" + " return 2; }" + ) + module = self.parse(source) + kinds = {symbol.name: symbol.kind for symbol in module.symbols} + self.assertEqual(kinds["View.Card"], "function") + self.assertEqual(kinds["Outer"], "function") + self.assertNotIn("component", kinds.values()) + + def test_generator_function_is_not_a_component(self): + module = self.parse("function* Feed(){ return ; }") + self.assertEqual(next(symbol for symbol in module.symbols if symbol.name == "Feed").kind, + "function") + + def test_secret_named_object_method_body_is_redacted(self): + source = ( + 'function T(){ return ; }' + ) + rendered = self.il(source) + self.assertNotIn("method-secret", rendered) + + def test_secret_named_method_signature_literals_are_redacted(self): + methods = { + "param-secret": 'password(value="param-secret") { return value; }', + "type-secret": 'password(): "type-secret" { return value; }', + "generic-secret": 'password() { return value; }', + } + for secret, method in methods.items(): + with self.subTest(secret=secret): + source = f"function T(){{ return ; }}" + self.assertNotIn(secret, self.il(source, "L3")) + + def test_secret_named_class_method_metadata_and_flow_are_redacted(self): + source = ( + 'class C { password(value="param-secret"): "type-secret" {' + ' throw "raise-secret"; return "body-secret"; } }' + ) + module = kern_compile.parse_tsjs(source, typescript=True) + rendered = kern_compile.emit_il( + module, "app/C.ts", "c" * 64, "none", "L3", + ) + for secret in ("param-secret", "type-secret", "raise-secret", "body-secret"): + self.assertNotIn(secret, rendered) + + def test_secret_named_type_method_signature_is_redacted(self): + secret = "quartz-velvet-7319" + source = f'type Config = {{ password(): "{secret}" }};' + module = kern_compile.parse_tsjs(source, typescript=True) + rendered = kern_compile.emit_il( + module, "app/Config.ts", "c" * 64, "none", "L3", + ) + self.assertNotIn(secret, rendered) + + def test_secret_named_method_call_metadata_is_redacted(self): + secret = "quartz-velvet-7319" + source = f'class C {{ password() {{ client["{secret}"](); }} }}' + module = kern_compile.parse_tsjs(source, typescript=True) + for tier in ("L1", "L2", "L3"): + with self.subTest(tier=tier): + rendered = kern_compile.emit_il( + module, "app/C.ts", "c" * 64, "none", tier, + ) + self.assertNotIn(secret, rendered) + + def test_render_prop_attribute_is_faulted(self): + source = "function T(){ return }/>; }" + for tier in ("L1", "L2", "L3"): + rendered = self.il(source, tier) + self.assertIn("!FAULT(render-prop)", rendered) + self.assertIn("render-prop(L1)", rendered) + + def test_guard_returns_are_explicitly_faulted(self): + source = "function T({ready}){ if (ready) return ; return ; }" + for tier in ("L1", "L2", "L3"): + rendered = self.il(source, tier) + self.assertIn("render-control-flow", rendered) + + def test_output_is_deterministic(self): + source = "function T({ok}){ return ok ? : ; }" + self.assertEqual(self.il(source), self.il(source)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_react_wrapper_regressions.py b/tests/test_react_wrapper_regressions.py new file mode 100644 index 0000000..07f36e5 --- /dev/null +++ b/tests/test_react_wrapper_regressions.py @@ -0,0 +1,131 @@ +import shutil +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "skills" / "kern" / "scripts")) +import kern_cache # noqa: E402 +import kern_compile # noqa: E402 + + +@unittest.skipUnless(kern_compile.tsjs_available(tsx=True), "tsx grammar not installed") +class TestReactWrapperRegressions(unittest.TestCase): + def parse(self, source): + return kern_compile.parse_tsjs(source, tsx=True) + + def symbol(self, module, name="Row"): + return next(symbol for symbol in module.symbols if symbol.name == name) + + def test_non_jsx_wrapper_probe_preserves_const_symbol(self): + cases = { + "memo": "const Row = memo((value) => value + 1);\n", + "forwardRef": "const Row = forwardRef((props, ref) => props.value);\n", + "React.memo": "const Row = React.memo((value) => value + 1);\n", + "React.forwardRef": ( + "const Row = React.forwardRef((props, ref) => props.value);\n" + ), + } + for wrapper, source in cases.items(): + with self.subTest(wrapper=wrapper): + module = self.parse(source) + symbol = self.symbol(module) + self.assertEqual(module.frontend, "tree-sitter") + self.assertEqual(symbol.kind, "const") + self.assertEqual(symbol.detail, "=" + source.split("=", 1)[1].strip().rstrip(";")) + self.assertEqual(symbol.react, {}) + self.assertEqual(len([item for item in module.symbols if item.name == "Row"]), 1) + + def test_inline_comparator_is_part_of_component_source_identity(self): + original = ( + "const Row = memo(\n" + " ({ id }) =>
  • {id}
  • ,\n" + " (previous, next) => previous.id === next.id\n" + ");\n" + ) + changed = original.replace("previous.id === next.id", "previous.key === next.key") + + before_module = kern_compile.apply_semantic_handles(self.parse(original)) + after_module = kern_compile.apply_semantic_handles(self.parse(changed)) + before = self.symbol(before_module) + after = self.symbol(after_module) + + self.assertEqual(before.kind, "component") + self.assertEqual(before.react["wrapper"], "memo") + self.assertEqual(before.span, (1, 4)) + self.assertEqual(after.span, before.span) + self.assertNotEqual(after.slice8, before.slice8) + self.assertNotEqual(after.semantic8, before.semantic8) + + def test_declaration_keyword_is_part_of_component_source_identity(self): + original = "const\nRow = memo(() => );\n" + changed = original.replace("const", "var") + + before = self.symbol(kern_compile.apply_semantic_handles(self.parse(original))) + after = self.symbol(kern_compile.apply_semantic_handles(self.parse(changed))) + + self.assertEqual(before.span, (1, 2)) + self.assertEqual(after.span, before.span) + self.assertNotEqual(after.slice8, before.slice8) + self.assertNotEqual(after.semantic8, before.semantic8) + + def test_typed_wrapper_callbacks_are_unwrapped(self): + callbacks = { + "parenthesized": "(({ id }: P) =>
  • {id}
  • )", + "as": "((({ id }: P) =>
  • {id}
  • ) as React.FC

    )", + "satisfies": "((({ id }: P) =>

  • {id}
  • ) satisfies React.FC

    )", + "non-null": "((({ id }: P) =>

  • {id}
  • )!)", + } + for label, callback in callbacks.items(): + with self.subTest(label=label): + module = self.parse(f"const Row = memo({callback});\n") + symbol = self.symbol(module) + self.assertEqual(module.frontend, "tree-sitter+react") + self.assertEqual(symbol.kind, "component") + self.assertEqual(symbol.react["wrapper"], "memo") + + def test_typed_wrapper_result_is_unwrapped(self): + module = self.parse( + "const Row = (memo(({id}: P) =>
  • {id}
  • ) as React.FC

    );\n" + ) + symbol = self.symbol(module) + self.assertEqual(module.frontend, "tree-sitter+react") + self.assertEqual(symbol.kind, "component") + self.assertEqual(symbol.react["wrapper"], "memo") + + def test_verify_reports_stale_after_inline_comparator_change(self): + original = ( + "const Row = memo(\n" + " ({ id }) =>

  • {id}
  • ,\n" + " (previous, next) => previous.id === next.id\n" + ");\n" + ) + changed = original.replace("previous.id === next.id", "previous.key === next.key") + module = kern_compile.apply_semantic_handles(self.parse(original)) + symbol = self.symbol(module) + + root = Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, root, ignore_errors=True) + source = root / "component.tsx" + source.write_text(changed) + paths, _ = kern_cache.initialize(root) + relative, normalized = kern_cache.normalize_rel(root, "component.tsx") + + result = kern_cache.verify_symbol( + root, + paths, + relative, + normalized, + "Row", + symbol.semantic8, + f"L{symbol.span[0]}-{symbol.span[1]}", + ) + + self.assertEqual(result["result"], "stale") + self.assertIs(result["ok"], False) + self.assertEqual(result["reason"], "source-handle-changed") + self.assertEqual(result["current_span"], "L1-4") + + +if __name__ == "__main__": + unittest.main()