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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1019,6 +1019,72 @@ jobs:
|| { echo "FAIL: explain --json did not harvest OWN001 from the SARIF log"; exit 1; }
echo "OK: explain answers a code and harvests codes from a real findings/SARIF file"

# The OwnTS frontend spike (P-020 Own.React): the SAME OwnIR seam, fed from a
# React .tsx instead of C#. Two analyses over the one core: (1) a useEffect
# acquire (timer / subscribe / listener) with no cleanup return is the core's
# OWN001 — the cross-language leak model; (2) EFF001, a NEW core analysis
# (ownlang/effects.py) — an IO effect whose dependency identity is unstable
# (a render-scope object literal) re-fires every render: the effect storm. The
# frontend emits only facts; the stability verdict is the core's. No dotnet.
ownts-react-effects:
name: OwnTS (React useEffect) -> OwnIR -> core
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.13"
- name: Pin the spike (leaky=3xOWN001+EFF001, clean=0, showcase=2xEFF001)
run: python frontend/ownts/test_ownts.py
- name: Extract OwnIR facts from a React .tsx and check through the core
run: |
python frontend/ownts/ownts.py frontend/ownts/examples/Dashboard.tsx \
-o "$RUNNER_TEMP/dash.facts.json"
cat "$RUNNER_TEMP/dash.facts.json"
out=$(python -m ownlang ownir "$RUNNER_TEMP/dash.facts.json" || true)
echo "$out"
echo "$out" | grep -q "Dashboard.tsx" \
|| { echo "FAIL: expected findings located at the .tsx"; exit 1; }
echo "$out" | grep -q "resource: timer" \
|| { echo "FAIL: expected the setInterval [resource: timer] leak"; exit 1; }
[ "$(echo "$out" | grep -c 'OWN001')" -eq 3 ] \
|| { echo "FAIL: expected three OWN001 leaks"; exit 1; }
echo "$out" | grep -q "\[EFF001\].*request storm" \
|| { echo "FAIL: expected the EFF001 effect-storm verdict"; exit 1; }
# exact finding count (a code-tagged line each), not a substring of "4 finding"
[ "$(echo "$out" | grep -cE '\[(OWN|EFF|DI)[0-9]{3}\]')" -eq 4 ] \
|| { echo "FAIL: expected 3 OWN001 + 1 EFF001 = 4 findings"; exit 1; }
- name: EFF001 stability showcase — only provable storms fire (low FP)
run: |
python frontend/ownts/ownts.py frontend/ownts/examples/EffectStorm.tsx \
-o "$RUNNER_TEMP/storm.facts.json"
storm=$(python -m ownlang ownir "$RUNNER_TEMP/storm.facts.json" || true)
echo "$storm"
# the direct object dep and its derived alias fire; memo/ref/call/primitive/no-IO stay silent
[ "$(echo "$storm" | grep -c '\[EFF001\]')" -eq 2 ] \
|| { echo "FAIL: expected exactly two EFF001 (object dep + derived alias)"; exit 1; }
echo "$storm" | grep -q "derives from" \
|| { echo "FAIL: expected the derivation (propagation) verdict"; exit 1; }
- name: Edge cases — partial timer cleanup + nested-scope shadow
run: |
python frontend/ownts/ownts.py frontend/ownts/examples/EffectEdges.tsx \
-o "$RUNNER_TEMP/edges.facts.json"
edges=$(python -m ownlang ownir "$RUNNER_TEMP/edges.facts.json" || true)
echo "$edges"
# only the SECOND, uncleared interval leaks; the memoized dep is not shadowed
[ "$(echo "$edges" | grep -cE '\[(OWN|EFF|DI)[0-9]{3}\]')" -eq 1 ] \
|| { echo "FAIL: expected exactly one OWN001 (the uncleared timer)"; exit 1; }
echo "$edges" | grep -q "pollB" \
|| { echo "FAIL: the leak must be the second (uncleared) interval"; exit 1; }
- name: The clean fixture (cleanups + useMemo'd dep) is silent
run: |
python frontend/ownts/ownts.py frontend/ownts/examples/DashboardClean.tsx \
-o "$RUNNER_TEMP/clean.facts.json"
clean=$(python -m ownlang ownir "$RUNNER_TEMP/clean.facts.json" || true)
echo "$clean"
[ "$(echo "$clean" | grep -cE '\[(OWN|EFF|DI)[0-9]{3}\]')" -eq 0 ] \
|| { echo "FAIL: cleaned-up + memoised effects must not fire"; exit 1; }

# The distribution surface (Уровень 1): the own-check.sh orchestrator walks a
# directory of real C# and prints findings in the host-parseable formats the
# GitHub Action (PR annotations) and a VS Error List (MSBuild) consume — and
Expand Down
29 changes: 23 additions & 6 deletions docs/proposals/P-020-ownts-react-effects.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,17 @@
- **Status:** draft — **experiment / proposal, explicitly not mainline.** A
marketing-shaped spike under the OwnTS frontend, on the record so the framing is
honest before any code.
- **Spike landed:** the honest `EFF003`/`EFF004` slice (timer / subscribe /
listener acquire with no cleanup `return` → core `OWN001`) is implemented in
`frontend/ownts/` and CI-pinned (the `ownts-react-effects` job).
- **`EFF001` is now a real core analysis** (`ownlang/effects.py`), answering Open
question 1 below: a self-contained **dependency-identity stability** lattice with
reference propagation, computed by the **core** over an OwnIR `effects` fact block
the frontend emits — *not* a frontend heuristic, and *not* an `OWN001`. It is its
own core code (`EFF001`), exactly as the DI captive check is `DI001`. The frontend
states only what each render-scope binding syntactically is; the core decides
stability. `EFF002` (network IO with no stable guard) is governed by the same
analysis and left as the next increment.
- **Depends on:** [P-017](P-017-multi-stack-frontends.md) (the OwnTS frontend &
confidence tiers that this profile feeds), `spec/OwnCore.md` (the acquire/release
vocabulary), [P-004](P-004-wpf-lifetime-profile.md) (WPF — the same lifecycle
Expand Down Expand Up @@ -145,12 +156,18 @@ has on the .NET side (P-013/P-015); 1/2/6/7 are this proposal's actual work.

## Open questions

1. **The new analysis for `EFF001/002`.** Detecting "dependency identity is unstable
across renders" needs render-scope object-literal/identity reasoning the core
has no model for. Is that a small, self-contained *stability* fact
(`unstable(dep, effect)`) the OwnTS frontend can emit and the core treat like an
acquire-site, or a genuinely new core analysis? This is the gating question — do
not let `EFF001` masquerade as an `OWN001` leak.
1. **The new analysis for `EFF001`.** ✅ **Answered (implemented).** It is a
genuinely new core analysis, not an `OWN001` acquire-site — and *not* a frontend
verdict either. The resolution: the frontend emits per-binding **facts** (each
render-scope binding's syntactic `init` kind + the names it references, the dep
list, whether the body does IO) in an OwnIR `effects` block; the **core**
(`ownlang/effects.py`) runs an identity-stability lattice (`STABLE < UNKNOWN <
UNSTABLE`) to a fixpoint over the references and decides `unstable(dep)`. An
effect is `EFF001` iff it does IO **and** a dep is *provably* `UNSTABLE`
(`UNKNOWN`/memoised/primitive clear it — low false positives). The gating
discipline held: `EFF001` is its own core code (like `DI001`), never an `OWN001`.
`EFF002` (network IO with no stable guard) is intended to reuse this same
lattice but is **not yet implemented** — it remains the next increment.
2. **Confidence tier.** `EFF001` clearly wants TS-mode type info (is the dep an
object literal? does the body do IO?). What, if anything, survives into JS mode
(P-017's heuristic tier) as a warning?
Expand Down
89 changes: 89 additions & 0 deletions frontend/ownts/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# OwnTS — React `useEffect` frontend spike (`Own.React`)

The TypeScript sibling of the [Roslyn C# extractor](../roslyn/). It scans a
React `.tsx`, emits **OwnIR** facts, and lets the **existing** Python core
(`python -m ownlang ownir`) flag the leak. Same seam, same checker, different
skin — a `useEffect` acquire without a cleanup `return` is the core's `OWN001`,
exactly as a C# `event +=` without `-=` is.

> The same OwnIR idea behind WPF subscription leaks can model React effect storms.

This is a **spike**, per [P-020](../../docs/proposals/P-020-ownts-react-effects.md)
— deliberately *not* a TypeScript analyzer. Extraction is heuristic (brace
matching + cleanup-verb detection), not a real TS parse. Its only job is to prove
the seam is cross-language.

## Run it

```bash
# print the OwnIR facts extracted from a .tsx
python frontend/ownts/ownts.py frontend/ownts/examples/Dashboard.tsx

# extract + run straight through the core (the "catch")
python frontend/ownts/ownts.py frontend/ownts/examples/Dashboard.tsx --check

# the EFF001 stability showcase (propagation + the conservative silent cases)
python frontend/ownts/ownts.py frontend/ownts/examples/EffectStorm.tsx --check

# or the real two-step CLI, same as the C# side:
python frontend/ownts/ownts.py frontend/ownts/examples/Dashboard.tsx -o facts.json
python -m ownlang ownir facts.json --format sarif

# pin the spike
python frontend/ownts/test_ownts.py
```

`Dashboard.tsx` drops three `OWN001` leaks **and** one `EFF001` effect storm;
`DashboardClean.tsx` (every acquire has a cleanup `return`, and the unstable dep is
`useMemo`'d) is silent.

```text
Dashboard.tsx:13: error: [OWN001] timer 'setInterval(() =>' ... never stopped ... (leak) [resource: timer]
Dashboard.tsx:21: error: [OWN001] the result of '.subscribe(...)' is ignored ... (leak) [resource: subscription token]
Dashboard.tsx:27: error: [OWN001] event '.addEventListener(...)' ... never unsubscribed ... (leak) [resource: subscription token]
Dashboard.tsx:34: error: [EFF001] effect re-runs on every render: dependency 'filters' is an object literal ... can become a request storm ... [resource: react effect]
```

## What it catches — the honest `Own.React` slice

| EFF | Pattern | OwnIR fact | Core verdict |
|-----|---------|-----------|--------------|
| `EFF004` | `setInterval`/`setTimeout` in an effect, no `clearInterval`/`clearTimeout` cleanup | `resource: timer` | `OWN001` |
| `EFF003` | `X.subscribe(...)` with no `unsubscribe` cleanup | `resource: subscribe` | `OWN001` |
| `EFF003` | `addEventListener` with no `removeEventListener` cleanup | `resource: subscription` | `OWN001` |
| `EFF001` | IO effect with a render-unstable dependency identity | `effects` block | `EFF001` (new core analysis — see below) |

These three **are** the existing acquire→release model, just emitted by an OwnTS
frontend. The core is untouched.

## EFF001 — a real core analysis (not a heuristic, not OWN001)

`EFF001` — the unstable-dependency "effect storm" (the Cloudflare 12-Sep-2025
shape: a `useEffect` whose dep object is re-created each render, re-firing the
effect and storming the API) — **is not an acquire/release leak.** It is a new core
analysis: **dependency-identity stability** (`ownlang/effects.py`), its own core
code like `DI001`, *never* an `OWN001`.

The honest split is preserved end-to-end. The frontend emits only **facts** — for
each `useEffect`, its dep list, whether the body does IO, and a render-scope
**binding table** (what each binding syntactically is: `object`/`array`/`new`,
`memo`/`callback`/`ref`, `ident` derivation + the names it references, `call`, …).
It does **not** pre-judge stability. The **core** runs an identity-stability lattice
(`STABLE < UNKNOWN < UNSTABLE`) to a fixpoint over the references and decides:

```text
EFF001 fires ⟺ the effect does IO ∧ some dep is *provably* UNSTABLE
```

- object/array literal in render scope → UNSTABLE (fresh identity every render)
- `useMemo`/`useCallback`/`useRef`, prop, primitive → STABLE
- an alias/derivation → the worst of what it references (instability *propagates*)
- an opaque `call(...)` → UNKNOWN → **no finding** (conservative; low false positives)

See `examples/EffectStorm.tsx`: two storms fire (a direct object dep and the alias
that derives from it — `derives from 'filters' ... (via alias -> filters)`), while
the memoised, ref, opaque-call, primitive, and no-IO effects all stay silent.

The one-liner the spike pays for: **"Not all lifecycle bugs leak memory. Some
leak requests."** We make **no** "Own would have prevented the Cloudflare outage"
claim — see P-020's Non-goals.
40 changes: 40 additions & 0 deletions frontend/ownts/examples/Dashboard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Leaky dashboard — three useEffect acquires with NO cleanup return.
// Each is the React skin of the same acquire->release contract the .NET core
// already checks (timer / subscription token). Run:
//
// python frontend/ownts/ownts.py frontend/ownts/examples/Dashboard.tsx --check
//
// Expect three OWN001 findings (EFF004 timer, EFF003 subscribe, EFF003 listener)
// plus one EFF001 effect-storm finding (the unstable `filters` dependency below).
import { useEffect } from "react";

export function Dashboard({ tenantId }: { tenantId: string }) {
// EFF004 — interval started, never cleared: the timer keeps the component alive.
useEffect(() => {
const id = setInterval(() => {
fetch(`/api/tenant/${tenantId}/metrics`);
}, 1000);
// no `return () => clearInterval(id)` — leak
}, [tenantId]);

// EFF003 — observable subscription whose teardown is never returned.
useEffect(() => {
bus.subscribe((msg) => console.log(msg));
// no `return () => sub.unsubscribe()` — leak
}, []);

// EFF003 — DOM listener added, never removed.
useEffect(() => {
window.addEventListener("resize", onResize);
// no `return () => window.removeEventListener("resize", onResize)` — leak
}, []);

// EFF001 (a separate core analysis — NOT an OWN001 leak): `filters` is a fresh
// object identity every render, and the effect does IO -> request storm.
const filters = { tenantId };
useEffect(() => {
fetch(`/api/tenant/${filters.tenantId}`);
}, [filters]);

return <div>dashboard</div>;
}
33 changes: 33 additions & 0 deletions frontend/ownts/examples/DashboardClean.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Clean dashboard — every acquire has a matching cleanup return, and the unstable
// dependency is stabilised with useMemo. The extractor sees `released: true` for
// each resource, so the core stays silent (zero findings). Run:
//
// python frontend/ownts/ownts.py frontend/ownts/examples/DashboardClean.tsx --check
import { useEffect, useMemo } from "react";

export function Dashboard({ tenantId }: { tenantId: string }) {
useEffect(() => {
const id = setInterval(() => {
fetch(`/api/tenant/${tenantId}/metrics`);
}, 1000);
return () => clearInterval(id); // released
}, [tenantId]);

useEffect(() => {
const sub = bus.subscribe((msg) => console.log(msg));
return () => sub.unsubscribe(); // released
}, []);

useEffect(() => {
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize); // released
}, []);

// stable identity across renders -> no effect storm
const filters = useMemo(() => ({ tenantId }), [tenantId]);
useEffect(() => {
fetch(`/api/tenant/${filters.tenantId}`);
}, [filters]);

return <div>dashboard</div>;
}
24 changes: 24 additions & 0 deletions frontend/ownts/examples/EffectEdges.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Edge cases the per-resource + render-scope fixes must get right (Codex/CodeRabbit):
// python frontend/ownts/ownts.py frontend/ownts/examples/EffectEdges.tsx --check
// Expect exactly ONE finding: OWN001 on the second, uncleared interval. No EFF001.
import { useEffect, useMemo } from "react";

export function EdgeBoard({ id }: { id: string }) {
// Two timers; only the first is cleared -> the SECOND still leaks. A kind-level
// "is there any clearInterval?" check would wrongly mark both released.
useEffect(() => {
const a = setInterval(pollA, 1000);
const b = setInterval(pollB, 2000);
return () => clearInterval(a); // only `a` cleared; `b` leaks (one OWN001)
}, []);

// The render-scope dep is memoized (stable). A like-named local INSIDE the effect
// callback must not shadow it into a false EFF001 storm.
const filters = useMemo(() => ({ id }), [id]);
useEffect(() => {
const filters = { id }; // nested-scope local — NOT the component's render scope
fetch(`/api/${filters.id}`);
}, [filters]); // refers to the stable outer (memoized) `filters`

return <div>edges</div>;
}
54 changes: 54 additions & 0 deletions frontend/ownts/examples/EffectStorm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// EFF001 showcase — the core's dependency-identity *stability* analysis, not a
// leak. Only the IO effects whose dep is PROVABLY unstable fire; memoised,
// primitive, opaque-call and no-IO cases stay silent (low false positives).
//
// python frontend/ownts/ownts.py frontend/ownts/examples/EffectStorm.tsx --check
//
// Expect exactly two EFF001 findings: the direct object dep, and the alias that
// derives from it.
import { useEffect, useMemo, useRef } from "react";

export function StormBoard({ tenantId }: { tenantId: string }) {
// (1) FIRES — fresh object identity every render + IO.
const filters = { tenantId };
useEffect(() => {
fetch(`/api/tenant/${filters.tenantId}`);
}, [filters]);

// (2) FIRES — `alias` derives from the unstable `filters`; instability propagates.
const alias = filters;
useEffect(() => {
fetch(`/api/alias/${alias.tenantId}`);
}, [alias]);

// (3) SILENT — useMemo gives a stable identity across renders.
const stable = useMemo(() => ({ tenantId }), [tenantId]);
useEffect(() => {
fetch(`/api/stable/${stable.tenantId}`);
}, [stable]);

// (4) SILENT — useRef identity is stable.
const box = useRef({ tenantId });
useEffect(() => {
fetch(`/api/ref/${box.current.tenantId}`);
}, [box]);

// (5) SILENT — opaque call: the core stays conservative (UNKNOWN, no finding).
const computed = makeFilters(tenantId);
useEffect(() => {
fetch(`/api/computed/${computed.tenantId}`);
}, [computed]);

// (6) SILENT — primitive dependency has a stable value identity.
useEffect(() => {
fetch(`/api/primitive/${tenantId}`);
}, [tenantId]);

// (7) SILENT — unstable dep but NO IO: re-running is cheap, not a storm.
const opts = { verbose: true };
useEffect(() => {
console.log(opts.verbose);
}, [opts]);

return <div>storm board</div>;
}
Loading
Loading