diff --git a/TRACKER.md b/TRACKER.md
index eb0c418..dabdcc2 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -5,8 +5,8 @@
## Status
- **Current phase:** 6F — Field hardening, feedback round 1 (runs before 6.1–6.5)
-- **Next step:** ship **0.4.0** to the tester for a re-validation round; then 6F.6 test-coverage hardening (blocked: needs the field's actual failing variant — the fixture's custom-wrapper shape already passes; get a failing test file from the tester or ship the defensive `coverage-unmapped` half only)
-- **Done:** 0.1–0.4, 1.1–1.6, 2.1–2.5, 3.1–3.6, 4.1–4.6, 5.1–5.7, 6F.1–6F.5, 6F.7–6F.8 · **release 0.4.0 prepared** (all packages bumped, changelog + version strings, `npm pack` → ui-lineage-0.4.0.tgz verified, publish pending)
+- **Next step:** publish 0.4.1 to the tester (all shippable fixes: 6F.6–6F.10 + visualizer). 6F.6 detection half remains blocked on a real failing test file — its defensive `coverage-unmapped` half shipped.
+- **Done:** 0.1–0.4, 1.1–1.6, 2.1–2.5, 3.1–3.6, 4.1–4.6, 5.1–5.7, 6F.1–6F.5, 6F.7–6F.10 + 6F.6 (defensive half) · **0.4.1 prepared** (all packages bumped, changelog + version strings, `npm pack` → ui-lineage-0.4.1.tgz verified: both bins, no @coderadar leak; publish to npm pending). 0.4.0 tagged/merged earlier. **Self-validated on Grafana frontend** (6,461 files, 15,334 nodes / 18,367 edges in 72 s): 55 RTK-query data sources, 32 routes, 1,009 coverage edges, gibberish declines — all previously 0/1 in the field run.
- **Gates passed:** Gate 0 (CI + red-path, #5/#6) · Gate 1 (precision 1.000, recall 0.895, zero poison) · Gate 2 (C1 instance attribution 1.000 · B1 4-level handler chains · C6 store writers↔readers · A9 portals — scorecard 137/0/0, precision & recall 1.000) · Gate 3 (B3 action effects · B4 routers · B6 cyclic journeys terminate · B7/B8 form & non-JSX events · G5 flag/role conditions — precision & recall 1.000) · Gate 4 (A4 rarity · A10 fuzzy/OCR · A1 structural · A6 subtree · E3 vision annotations · E2 aliases · G4 corrections — high-conf correct 1.000, ambiguity honesty 1.000, poison rate 0.000) · Gate 5 (F1 context bundle · F2 blast radius · F3 test coverage · F4 response schema · F5 git history · MCP server over stdio — scorecard 265/0/0, all honesty metrics 1.000; **M5 reached** — ticket in → budgeted context bundle out, over MCP)
## What CodeRadar is
@@ -477,7 +477,7 @@ unmarked, so **the fixture is fully green with zero marks and Gate 6F's extracto
are met**. 4 new parser tests (129 total); eval 290 pass / 0 fail / 0 xfail / 0
unexpected-pass, gate OK, metrics 1.000.
-### [ ] 6F.6 Test-coverage detection hardening
+### [~] 6F.6 Test-coverage detection hardening
**Failure modes:** F3
**Build:** the field run found 1 `covered-by` edge app-wide, making `untested` warnings
near-universal noise. Handle custom render wrappers (`renderWithProviders()` — resolve
@@ -488,6 +488,16 @@ through to the JSX argument), test files importing through the same alias/barrel
**Accept:** field-patterns fixture: wrapped renders produce `covered-by` edges; a near-empty
coverage graph emits `coverage-unmapped` instead of per-component `untested`; enable the 6F.2
checks.
+**Done (defensive half):** the `coverage-unmapped` downgrade shipped — `buildBundle` now, when
+test files exist but < 5% of components carry a `covered-by` edge, emits one graph-level
+`coverage-unmapped — only N/M components have mapped test coverage` note instead of a
+near-universal false `untested`. A genuinely test-free repo (no test nodes) keeps the accurate
+per-component `untested`. 3 agent-sdk unit tests (near-empty → downgrade · healthy → keep ·
+no-tests → keep); verified on the real Grafana graph (34% coverage → per-component `untested`
+preserved). **Detection half still open** (custom-wrapper/alias/outside-`__tests__` resolution):
+could NOT reproduce the field failure — the fixture's `renderWithProviders` shape already maps,
+and Grafana produced 1,009 `covered-by` edges, so detection works on real code. Blocked on a
+real failing test file from the tester before building the wrong thing.
### [x] 6F.7 Scoring & result-surface polish
**Failure modes:** A4, D2, D6
@@ -537,9 +547,53 @@ round-trips, dangling-edge drop, `` breakout guard, self-contained asse
README documents the command. eval unaffected (290/0/0/0). **Gate 6F extractor criteria met;
only 6F.6 (coverage, data-blocked) remains before the gate fully closes.**
+### [x] 6F.9 Stopword & rare-literal scoring noise
+**Failure modes:** A15 (new), A4, D6
+**Build:** self-found while validating 0.4.0 on Grafana's frontend — `find "Find silences by
+matcher"` ranked `OrderBySection` (renders a bare `BY`) top-1 over `SilencesFilter`, because a
+lone stopword is a *rare literal* with high IDF; and `find "The"` returned confident matches on
+"the". New `isLowSignal(normalized)` in core/text.ts (extends the A14 `hasMatchSignal` guard):
+true when a string is empty, punctuation-only, or entirely stopwords (folded through
+`foldPlural` so it compares to normalized text). `matchComponents` now drops stopword-only query
+terms and skips stopword-only rendered-text targets, so the exact-phrase component wins and a
+stopword-only query declines `no-signal`.
+**Accept:** new fixture `a15-stopword-noise` (a `BY` component, an exact-phrase component, a
+`the`-rendering component): the phrase query ranks the exact-phrase component top-1; a stopword
+query declines; a stopword mixed with a real term ignores the stopword-rendering component.
+**Done:** implemented + 5 unit tests (2 text, 3 matcher) + fixture. **Verified on the real
+Grafana graph:** `Find silences by matcher` → `SilencesFilter` top-1 (`OrderBySection` gone),
+`The` → `no-signal`, and `resolve` on a stopword-heavy ticket went from confidently-wrong to an
+honest `no-signal`. eval 297/0/0/0, gate OK, all metrics 1.000. A16 (HTML-entity rendered text)
+spun out to 6F.10 — out of scope for a scoring PR.
+
+### [x] 6F.10 HTML-entity rendered-text noise
+**Failure modes:** A16 (new)
+**Build:** self-found on Grafana — rendered text that is an HTML entity (` `, `"`,
+`>`) normalizes to a junk token (`nbsp`, `34`, `gt`); numeric entities make gibberish
+containing those digits match. Decode/strip HTML entities in the parser's text-extraction pass
+(parser-react), or treat entity-only rendered text as low-signal. Not a scoring fix — belongs in
+extraction, hence a separate step from 6F.9.
+**Accept:** fixture with entity-only rendered text: it produces no match target; gibberish that
+shares digits with a numeric entity declines. eval green.
+**Done:** new `entities.ts` in parser-react — `decodeEntities(text)` resolves the HTML entities
+React decodes at render time (numeric decimal `"` / hex `"` generically, named entities
+from a curated map: markup, whitespace, punctuation, symbols, currency, accented Latin-1;
+unknown names left verbatim, matching React). `extractRenderedText` decodes JSX text and quoted
+attribute values — the two surfaces React HTML-decodes — while JS string/template literals stay
+untouched (React renders `{" "}` literally). Decoded entities become the character React
+renders, which the normalizer strips: ` `→space→dropped, `>`/`"`/`·`→
+punctuation that normalizes to empty, so an entity-only component yields **no discriminating
+target** (verified: `EntitySpacer.renderedText` = `["\"", ">", "·", "<", "›"]`, zero
+alphanumeric tokens). New fixture `a16-html-entities` (entity-only `EntitySpacer` + real
+`QuotaNotice`): the named-entity token `nbsp`, the numeric-entity token `34`, and a gibberish
+query sharing those digits (`zzqwxnomatch12345`, which pre-fix matched via `"`→"34") all
+decline `no-signal`, while the real query still lands on `QuotaNotice` and isn't poisoned by the
+digit-sharing gibberish. 7 new tests (4 unit decode + 3 fixture integration), 136 parser-react
+total; eval 304/0/0/0, gate OK, all metrics 1.000.
+
**Gate 6F:** field-patterns fixture fully green (skip list empty ✅) · instance resolution ≥ 95%
(✅ 100%) · RTK data sources > 0 (✅) · route nodes > 0 (✅) · gibberish queries decline
-`no-signal` (✅) · `pnpm eval` green end-to-end (✅ 290/0/0/0). Remaining before the gate is
+`no-signal` (✅) · `pnpm eval` green end-to-end (✅ 304/0/0/0). Remaining before the gate is
formally stamped: 6F.6 test-coverage hardening (blocked on a real failing sample).
---
diff --git a/docs/failure-modes.md b/docs/failure-modes.md
index 8a7c009..323ca4b 100644
--- a/docs/failure-modes.md
+++ b/docs/failure-modes.md
@@ -31,6 +31,8 @@ every downstream agent. When in doubt, return ranked candidates with evidence, o
| A12 | **Non-text UI** — charts, icons, canvases | Nothing to text-match | Graceful degradation: structural candidates + explicit low confidence | 4 |
| A13 | **Third-party embeds** — Intercom, iframes | Content isn't ours | Detect and report "not in this codebase" | 4 |
| A14 | **Empty-normalizing rendered text** — components rendering bare `\|` / `/` / `-` | Normalization strips punctuation → empty target treated as a substring of every query; matcher becomes a universal wildcard (field-found in v0.3.0) | Discard targets that normalize to empty; minimum alphanumeric token length; `no-signal` decline when only such targets match | 6F |
+| A15 | **Stopword & rare-literal noise** — a component renders a bare `BY`; a ticket says "The …" | A stopword is a rare literal with high IDF, so it outranks the exact-phrase match; stopword-only queries return confident junk (field-found on Grafana 0.4.0) | Drop stopword-only query terms; reject stopword-only rendered-text targets (extends A14's low-signal guard) | 6F |
+| A16 | **HTML-entity rendered text** — a component renders ` ` / `"` / `>` | Entities normalize to junk tokens (`nbsp`, `34`, `gt`); numeric entities let gibberish sharing those digits match (self-found on Grafana 0.4.0) | Decode/strip HTML entities in the text-extraction pass, or treat entity-only rendered text as low-signal | 6F |
## B. User-journey extraction
diff --git a/eval/fixtures/a15-stopword-noise/app/AckPanel.tsx b/eval/fixtures/a15-stopword-noise/app/AckPanel.tsx
new file mode 100644
index 0000000..f66dcbd
--- /dev/null
+++ b/eval/fixtures/a15-stopword-noise/app/AckPanel.tsx
@@ -0,0 +1,3 @@
+export function AckPanel() {
+ return
Before you continue, acknowledge the following
;
+}
diff --git a/eval/fixtures/a15-stopword-noise/app/OrderBySection.tsx b/eval/fixtures/a15-stopword-noise/app/OrderBySection.tsx
new file mode 100644
index 0000000..d3ac3d7
--- /dev/null
+++ b/eval/fixtures/a15-stopword-noise/app/OrderBySection.tsx
@@ -0,0 +1,8 @@
+export function OrderBySection() {
+ return (
+
+
+ BY
+
+ );
+}
diff --git a/eval/fixtures/a15-stopword-noise/app/SilencesFilter.tsx b/eval/fixtures/a15-stopword-noise/app/SilencesFilter.tsx
new file mode 100644
index 0000000..08daad0
--- /dev/null
+++ b/eval/fixtures/a15-stopword-noise/app/SilencesFilter.tsx
@@ -0,0 +1,7 @@
+export function SilencesFilter() {
+ return (
+
+
+
+ );
+}
diff --git a/eval/fixtures/a15-stopword-noise/golden.json b/eval/fixtures/a15-stopword-noise/golden.json
new file mode 100644
index 0000000..162f03d
--- /dev/null
+++ b/eval/fixtures/a15-stopword-noise/golden.json
@@ -0,0 +1,17 @@
+{
+ "failureMode": "A15",
+ "note": "Stopword & rare-literal noise (field-found on Grafana, 0.4.0): a component rendering a bare stopword ('BY') is a rare literal whose high IDF let it outrank the component that renders the whole phrase, and a stopword-only query ('The') returned confident matches. Fix (6F.9): drop stopword-only query terms and reject stopword-only rendered-text targets, so the exact-phrase component wins and a stopword query declines no-signal.",
+ "expect": {
+ "components": [
+ { "name": "OrderBySection", "instances": 0 },
+ { "name": "SilencesFilter", "instances": 0 },
+ { "name": "AckPanel", "instances": 0 }
+ ],
+ "queries": [
+ { "terms": ["Find silences by matcher"], "status": "ok", "top": "SilencesFilter" },
+ { "terms": ["silences"], "status": "ok", "top": "SilencesFilter" },
+ { "terms": ["The"], "status": "declined" },
+ { "terms": ["the", "acknowledge"], "status": "ok", "top": "AckPanel" }
+ ]
+ }
+}
diff --git a/eval/fixtures/a16-html-entities/app/EntitySpacer.tsx b/eval/fixtures/a16-html-entities/app/EntitySpacer.tsx
new file mode 100644
index 0000000..9238de4
--- /dev/null
+++ b/eval/fixtures/a16-html-entities/app/EntitySpacer.tsx
@@ -0,0 +1,16 @@
+// Its only rendered text is HTML entities — the shape the Grafana frontend hit
+// (26 , 4 ", plus "/>/</·/›). Each decodes to
+// punctuation/whitespace, so after decoding there is no match target at all.
+export function EntitySpacer() {
+ return (
+
+
+ "
+ >
+ "
+ ·
+ <
+ ›
+
+ );
+}
diff --git a/eval/fixtures/a16-html-entities/app/QuotaNotice.tsx b/eval/fixtures/a16-html-entities/app/QuotaNotice.tsx
new file mode 100644
index 0000000..c19e603
--- /dev/null
+++ b/eval/fixtures/a16-html-entities/app/QuotaNotice.tsx
@@ -0,0 +1,6 @@
+// A real component with distinctive text, so the fixture isn't trivially
+// all-declining: a real query must still land here, and gibberish that shares
+// digits with a numeric entity must not poison it.
+export function QuotaNotice() {
+ return
Storage quota exceeded
;
+}
diff --git a/eval/fixtures/a16-html-entities/golden.json b/eval/fixtures/a16-html-entities/golden.json
new file mode 100644
index 0000000..0ab8827
--- /dev/null
+++ b/eval/fixtures/a16-html-entities/golden.json
@@ -0,0 +1,17 @@
+{
+ "failureMode": "A16",
+ "note": "HTML-entity rendered text (self-found on Grafana, 0.4.0): a component whose only rendered text is HTML entities ( , ", >, ", ·, <, ›) normalized to junk tokens ('nbsp', '34', 'gt'), creating spurious matches — and a numeric entity like " let a gibberish query sharing its digits ('...12345') match. Fix (6F.10): decode HTML entities in the parser's text-extraction pass so they become the character React renders, which normalizes away as punctuation. The entity-only component then produces no match target: a named-entity token ('nbsp'), a numeric-entity token ('34'), and a gibberish query that shares those digits all decline no-signal, while a real query still lands on the real component and isn't poisoned by the digit-sharing gibberish.",
+ "expect": {
+ "components": [
+ { "name": "EntitySpacer", "instances": 0 },
+ { "name": "QuotaNotice", "instances": 0 }
+ ],
+ "queries": [
+ { "terms": ["34"], "status": "declined" },
+ { "terms": ["nbsp"], "status": "declined" },
+ { "terms": ["zzqwxnomatch12345"], "status": "declined" },
+ { "terms": ["Storage quota exceeded"], "status": "ok", "top": "QuotaNotice" },
+ { "terms": ["zzqwxnomatch12345", "Storage quota exceeded"], "status": "ok", "top": "QuotaNotice" }
+ ]
+ }
+}
diff --git a/eval/package.json b/eval/package.json
index d51f585..4357a5c 100644
--- a/eval/package.json
+++ b/eval/package.json
@@ -1,6 +1,6 @@
{
"name": "@coderadar/eval",
- "version": "0.4.0",
+ "version": "0.4.1",
"private": true,
"description": "CodeRadar eval harness — scans failure-mode fixtures, diffs against golden outputs, emits the scorecard that gates every phase.",
"license": "MIT",
diff --git a/package.json b/package.json
index d436288..c1ee8ed 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "coderadar",
"private": true,
- "version": "0.4.0",
+ "version": "0.4.1",
"description": "Static analysis toolkit that maps UI components to their data sources (APIs, state, events) — enabling AI agents to trace any screenshot back to the code and data behind it.",
"license": "MIT",
"packageManager": "pnpm@11.2.2",
diff --git a/packages/agent-sdk/package.json b/packages/agent-sdk/package.json
index 4a3367c..c1f0328 100644
--- a/packages/agent-sdk/package.json
+++ b/packages/agent-sdk/package.json
@@ -1,6 +1,6 @@
{
"name": "@coderadar/agent-sdk",
- "version": "0.4.0",
+ "version": "0.4.1",
"private": true,
"description": "resolveContext orchestrator — classifies a ticket and runs the matching engine. Deterministic, no LLM in the node. Bundled into the published ui-lineage package.",
"license": "MIT",
diff --git a/packages/agent-sdk/src/bundle.test.ts b/packages/agent-sdk/src/bundle.test.ts
index 7d4ed67..b1f6591 100644
--- a/packages/agent-sdk/src/bundle.test.ts
+++ b/packages/agent-sdk/src/bundle.test.ts
@@ -97,4 +97,81 @@ describe("buildBundle (TRACKER 5.2, F1)", () => {
expect(ood.match).toEqual([]);
expect(ood.warnings.some((w) => w.includes("out-of-scope"))).toBe(true);
});
+
+ it("reports a plain 'untested' when the repo genuinely has no tests", () => {
+ // graphWithLineage has 1 component, 0 test nodes → untested is accurate.
+ const bundle = buildBundle(graph, { text: "the Big card overview" }, { budgetTokens: 8000 });
+ expect(bundle.warnings.some((w) => w.startsWith("untested —"))).toBe(true);
+ expect(bundle.warnings.some((w) => w.startsWith("coverage-unmapped"))).toBe(false);
+ });
+});
+
+describe("coverage-unmapped downgrade (TRACKER 6F.6, F3)", () => {
+ // A graph where test files EXIST but almost none map to a component — the
+ // field-found signature (tests present, ~0 covered-by edges). The matched
+ // component's missing coverage must read as "unmapped", not a false "untested".
+ function sparselyCoveredGraph(componentCount: number, coveredCount: number): LineageGraph {
+ const nodes: LineageGraph["nodes"] = [];
+ const edges: LineageEdge[] = [];
+ for (let i = 0; i < componentCount; i += 1) {
+ const name = i === 0 ? "SilencePanel" : `Widget${i}`;
+ const text = i === 0 ? "Silence alert notifications" : `Widget ${i} label`;
+ nodes.push({
+ id: nodeId("component", `${name}.tsx`, name),
+ kind: "component",
+ name,
+ loc: loc(`${name}.tsx`),
+ exportName: name,
+ props: [],
+ renderedText: [{ text, source: "jsx" }],
+ rendersComponents: [],
+ structure: {
+ table: 0, columns: 0, form: 0, input: 0, button: 0,
+ link: 0, image: 0, heading: 0, list: 0, repeated: 0,
+ },
+ });
+ }
+ // Several test files exist, but they only cover the LAST few components —
+ // never the matched SilencePanel (index 0).
+ for (let t = 0; t < 5; t += 1) {
+ const testId = nodeId("test", `Widget.test.tsx`, `test${t}`);
+ nodes.push({
+ id: testId,
+ kind: "test",
+ name: `test${t}`,
+ loc: loc(`__tests__/Widget${t}.test.tsx`),
+ framework: "vitest",
+ });
+ if (t < coveredCount) {
+ edges.push({ from: nodes[componentCount - 1 - t]!.id, to: testId, kind: "covered-by" });
+ }
+ }
+ return {
+ version: 2,
+ root: "/app",
+ generatedAt: "2026-01-01T00:00:00.000Z",
+ generator: "test",
+ nodes,
+ edges,
+ };
+ }
+
+ it("emits coverage-unmapped instead of untested when coverage is near-empty", () => {
+ // 40 components, tests present, only 1 covered (2.5% < 5% floor).
+ const g = sparselyCoveredGraph(40, 1);
+ const bundle = buildBundle(g, { text: "Silence alert notifications" }, { budgetTokens: 8000 });
+ expect(bundle.match[0]?.component).toBe("SilencePanel");
+ expect(bundle.warnings.some((w) => w.startsWith("coverage-unmapped"))).toBe(true);
+ expect(bundle.warnings.some((w) => w.startsWith("untested —"))).toBe(false);
+ });
+
+ it("keeps per-component untested when coverage is healthy", () => {
+ // 10 components, 5 covered (50% ≥ 5%) — the matched SilencePanel is not one
+ // of them, so untested is the accurate, useful signal.
+ const g = sparselyCoveredGraph(10, 5);
+ const bundle = buildBundle(g, { text: "Silence alert notifications" }, { budgetTokens: 8000 });
+ expect(bundle.match[0]?.component).toBe("SilencePanel");
+ expect(bundle.warnings.some((w) => w.startsWith("untested —"))).toBe(true);
+ expect(bundle.warnings.some((w) => w.startsWith("coverage-unmapped"))).toBe(false);
+ });
});
diff --git a/packages/agent-sdk/src/bundle.ts b/packages/agent-sdk/src/bundle.ts
index 2b6c70d..7293b16 100644
--- a/packages/agent-sdk/src/bundle.ts
+++ b/packages/agent-sdk/src/bundle.ts
@@ -186,7 +186,29 @@ export function buildBundle(
if (test !== undefined) testFiles.add(test.loc.file);
}
bundle.tests = [...testFiles].sort().map((file) => ({ file }));
- if (!topCovered) bundle.warnings.push(`untested — no test renders ${top.component.name}`);
+ if (!topCovered) {
+ // Graph-level coverage health (6F.6, F3): when test files exist but almost
+ // no component carries a covered-by edge, coverage mapping is effectively
+ // blind — a per-component "untested" is then a near-universal FALSE signal
+ // (the field-found noise: 1 covered-by edge across a whole app). Downgrade
+ // to one honest graph-level note. A genuinely test-free repo (no test
+ // nodes at all) keeps the accurate "untested".
+ let componentCount = 0;
+ let testCount = 0;
+ for (const node of graph.nodes) {
+ if (node.kind === "component") componentCount += 1;
+ else if (node.kind === "test") testCount += 1;
+ }
+ const coveredComponents = new Set();
+ for (const edge of graph.edges) if (edge.kind === "covered-by") coveredComponents.add(edge.from);
+ const coverageUnmapped =
+ testCount > 0 && componentCount > 0 && coveredComponents.size / componentCount < 0.05;
+ bundle.warnings.push(
+ coverageUnmapped
+ ? `coverage-unmapped — only ${coveredComponents.size}/${componentCount} components have mapped test coverage; "untested" is unreliable for this graph`
+ : `untested — no test renders ${top.component.name}`,
+ );
+ }
// Git history (5.6): recent commits touching the matched component's files.
const files = new Set([top.component.loc.file]);
diff --git a/packages/cli/README.md b/packages/cli/README.md
index 1a95d96..37fdc6f 100644
--- a/packages/cli/README.md
+++ b/packages/cli/README.md
@@ -113,6 +113,15 @@ Turn a screenshot into `{ terms, structure, annotations }` and match it — term
Endpoints (constants, templates, API wrappers, react-query/SWR), i18n text, cross-file instance trees & per-instance prop-flow, Redux/Zustand stores, portals/modals/toasts, React Router & Next.js routes, action effects (navigate/fetch/dispatch/setState), form libraries & non-JSX events (react-hook-form, `addEventListener`, hotkeys), and feature-flag/role conditions.
+## New in 0.4.1
+
+Second field-hardening round, validated by self-running against **Grafana's frontend** (6,461 files → 15,334-node graph in 72 s: 55 RTK-query data sources, 32 routes, 1,009 test-coverage edges):
+
+- **Stopword & rare-literal scoring** — a component rendering a bare `BY` (a rare literal with high IDF) no longer outranks the component that renders the whole phrase; stopword-only queries decline `no-signal`.
+- **HTML-entity rendered text** — ` ` / `"` / `>` are decoded during extraction (as React does), so they stop producing junk match tokens and false matches.
+- **`coverage-unmapped` note** — when test files exist but almost none map to a component, bundles emit one honest graph-level note instead of a near-universal false `untested`.
+- **`visualize`** — self-contained interactive HTML galaxy of the whole graph (`ui-lineage visualize -g app.graph.json -o app.galaxy.html`), responsive at 15k+ nodes.
+
## New in 0.4.0
Field-hardening from real-codebase validation (React 18 · Redux Toolkit · RTK Query · MUI · React Router):
diff --git a/packages/cli/package.json b/packages/cli/package.json
index 972f3ea..91920cd 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -1,6 +1,6 @@
{
"name": "ui-lineage",
- "version": "0.4.0",
+ "version": "0.4.1",
"description": "Map UI components to their data sources and user journeys — trace any screenshot back to the code, APIs, state, and navigation behind it. Deterministic static analysis for React/TSX.",
"license": "MIT",
"type": "module",
diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts
index 372d3b2..7bf217a 100644
--- a/packages/cli/src/index.ts
+++ b/packages/cli/src/index.ts
@@ -32,7 +32,7 @@ program
.description(
"Map UI components to their data sources and user journeys — trace any screenshot back to the code, APIs, state, and navigation behind it.",
)
- .version("0.4.0");
+ .version("0.4.1");
program
.command("scan")
diff --git a/packages/core/package.json b/packages/core/package.json
index 7fe8592..19a20ee 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -1,6 +1,6 @@
{
"name": "@coderadar/core",
- "version": "0.4.0",
+ "version": "0.4.1",
"private": true,
"description": "CodeRadar lineage graph schema — the language-agnostic contract every parser emits and every agent consumes. Bundled into the published `ui-lineage` package.",
"license": "MIT",
diff --git a/packages/core/src/matching.test.ts b/packages/core/src/matching.test.ts
index 0551bca..605f77c 100644
--- a/packages/core/src/matching.test.ts
+++ b/packages/core/src/matching.test.ts
@@ -227,3 +227,36 @@ describe("identifier affinity & top-line score (TRACKER 6F.7)", () => {
expect([...scores].sort((a, b) => b - a)).toEqual(scores);
});
});
+
+describe("stopword & rare-literal noise (TRACKER 6F.9, A15)", () => {
+ // Grafana field-found: `find "Find silences by matcher"` ranked OrderBySection
+ // (renders bare "BY") top-1 over SilencesFilter, because "BY" is a rare
+ // literal with huge IDF; and `find "The"` matched components on "the".
+ const g = graph([
+ component("OrderBySection", ["BY"]),
+ component("SilencesFilter", ["Find silences by matcher"]),
+ component("AckPanel", ["acknowledge the following"]),
+ ]);
+
+ it("the exact-phrase component outranks a rare stopword literal", () => {
+ const result = matchComponentsByText(g, ["Find silences by matcher"]);
+ expect(result.candidates[0]?.value.component.name).toBe("SilencesFilter");
+ const names = result.candidates.map((c) => c.value.component.name);
+ expect(names).not.toContain("OrderBySection");
+ });
+
+ it("a stopword-only query declines no-signal", () => {
+ const result = matchComponentsByText(g, ["The"]);
+ expect(result.status).toBe("declined");
+ expect(result.declineReason).toBe("no-signal");
+ });
+
+ it("stopwords in a real query don't pull in stopword-rendering components", () => {
+ // Word-level terms as a ticket extractor produces them: "the" is dropped,
+ // "silences" resolves — AckPanel (renders "the") must not surface.
+ const result = matchComponentsByText(g, ["the", "silences"]);
+ expect(result.status).toBe("ok");
+ expect(result.candidates[0]?.value.component.name).toBe("SilencesFilter");
+ expect(result.candidates.map((c) => c.value.component.name)).not.toContain("AckPanel");
+ });
+});
diff --git a/packages/core/src/query.ts b/packages/core/src/query.ts
index 38c339e..294682f 100644
--- a/packages/core/src/query.ts
+++ b/packages/core/src/query.ts
@@ -14,7 +14,7 @@ import {
ok,
type QueryResult,
} from "./result.js";
-import { fuzzyTokenMatch, normalizeText, textMatches, tokenize } from "./text.js";
+import { fuzzyTokenMatch, isLowSignal, normalizeText, textMatches, tokenize } from "./text.js";
import type {
ComponentNode,
DataSourceNode,
@@ -128,7 +128,9 @@ export function matchComponents(
graph: LineageGraph,
query: MatchQuery,
): QueryResult {
- const queryTerms = (query.terms ?? []).map((t) => normalizeText(t)).filter((t) => t.length > 1);
+ const queryTerms = (query.terms ?? [])
+ .map((t) => normalizeText(t))
+ .filter((t) => t.length > 1 && !isLowSignal(t));
const descriptor = query.structure;
if (queryTerms.length === 0 && descriptor === undefined) return declined("no-signal");
@@ -162,7 +164,12 @@ export function matchComponents(
const termTokens = tokenize(term);
if (termTokens.length === 0) return null;
for (const entry of component.renderedText) {
- if (textMatches(normalizeText(entry.text), term)) return entry;
+ const normEntry = normalizeText(entry.text);
+ // A stopword-only / punctuation-only entry ("BY", "|") is not a real
+ // target: it matches too much and, being a rare literal, its high IDF
+ // would let it outrank an exact-phrase match (A14/A15).
+ if (isLowSignal(normEntry)) continue;
+ if (textMatches(normEntry, term)) return entry;
if (containsPhrase(tokenize(entry.text), termTokens)) return entry;
}
return null;
diff --git a/packages/core/src/text.test.ts b/packages/core/src/text.test.ts
index 34eb160..2dc0348 100644
--- a/packages/core/src/text.test.ts
+++ b/packages/core/src/text.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
-import { hasMatchSignal, normalizeText, textMatches } from "./text.js";
+import { hasMatchSignal, isLowSignal, normalizeText, textMatches } from "./text.js";
describe("normalizeText", () => {
it("lowercases, strips punctuation, collapses whitespace", () => {
@@ -60,3 +60,20 @@ describe("hasMatchSignal", () => {
expect(hasMatchSignal("ok")).toBe(true);
});
});
+
+describe("isLowSignal (A15 — stopword/punctuation targets)", () => {
+ it("flags empty, punctuation-only, and stopword-only strings", () => {
+ expect(isLowSignal("")).toBe(true);
+ expect(isLowSignal(normalizeText("|"))).toBe(true);
+ expect(isLowSignal(normalizeText("BY"))).toBe(true); // rare literal → no signal
+ expect(isLowSignal(normalizeText("The"))).toBe(true);
+ expect(isLowSignal(normalizeText("of the"))).toBe(true);
+ expect(isLowSignal(normalizeText("This"))).toBe(true); // folds to "thi"
+ });
+
+ it("keeps strings with at least one discriminating token", () => {
+ expect(isLowSignal(normalizeText("silences"))).toBe(false);
+ expect(isLowSignal(normalizeText("Find silences by matcher"))).toBe(false);
+ expect(isLowSignal(normalizeText("the invoice"))).toBe(false);
+ });
+});
diff --git a/packages/core/src/text.ts b/packages/core/src/text.ts
index 66591bd..200aff7 100644
--- a/packages/core/src/text.ts
+++ b/packages/core/src/text.ts
@@ -61,6 +61,39 @@ export function textMatches(haystack: string, needle: string): boolean {
return haystack.includes(needle) || needle.includes(haystack);
}
+/**
+ * Common English function words that carry no discriminating match signal.
+ * Folded through `foldPlural` at load so they compare equal to normalized text
+ * (which is also folded) — e.g. "this" → "thi", "does" → "doe".
+ */
+const STOPWORDS = new Set(
+ [
+ "a", "an", "and", "are", "as", "at", "be", "been", "being", "but", "by",
+ "can", "could", "did", "do", "does", "for", "from", "had", "has", "have",
+ "he", "her", "his", "i", "if", "in", "into", "is", "it", "its", "just",
+ "may", "me", "might", "must", "my", "no", "not", "of", "on", "only", "or",
+ "our", "out", "over", "per", "she", "should", "so", "such", "than", "that",
+ "the", "their", "them", "then", "these", "they", "this", "those", "to",
+ "up", "us", "via", "was", "we", "were", "will", "with", "would", "you",
+ "your",
+ ].map(foldPlural),
+);
+
+/**
+ * True when a normalized string carries no *discriminating* signal: it is
+ * empty, punctuation-only (A14), or nothing but stopwords ("the", "by").
+ * Such a string is both a poor query term (matches everything) and a poor
+ * match target — a component rendering a bare "BY" is a rare literal whose
+ * high IDF would otherwise let it outrank the component that renders the whole
+ * phrase (failure mode A15, field-found on Grafana). `matchComponents` drops
+ * these from query terms and rejects them as rendered-text targets.
+ */
+export function isLowSignal(normalized: string): boolean {
+ if (!hasMatchSignal(normalized)) return true;
+ const tokens = normalized.split(" ").filter((t) => t.length > 0 && t !== "*");
+ return tokens.length > 0 && tokens.every((t) => STOPWORDS.has(t));
+}
+
function foldPlural(token: string): string {
if (token.length > 4 && token.endsWith("ies")) return `${token.slice(0, -3)}y`;
if (token.length > 3 && token.endsWith("s") && !token.endsWith("ss")) return token.slice(0, -1);
diff --git a/packages/mcp/package.json b/packages/mcp/package.json
index 354c6ae..068a67f 100644
--- a/packages/mcp/package.json
+++ b/packages/mcp/package.json
@@ -1,6 +1,6 @@
{
"name": "@coderadar/mcp",
- "version": "0.4.0",
+ "version": "0.4.1",
"private": true,
"description": "Model Context Protocol server exposing CodeRadar as a context-provider node: resolve_context, find_component, trace_lineage, journeys, blast_radius over a pre-built graph.",
"license": "MIT",
diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts
index d9856d6..eb19906 100644
--- a/packages/mcp/src/server.ts
+++ b/packages/mcp/src/server.ts
@@ -33,7 +33,7 @@ function envelope(value: unknown): ToolResult {
/** Build a configured MCP server bound to one pre-built graph. */
export function createServer(graph: LineageGraph): McpServer {
- const server = new McpServer({ name: "coderadar", version: "0.4.0" });
+ const server = new McpServer({ name: "coderadar", version: "0.4.1" });
server.registerTool(
"resolve_context",
diff --git a/packages/parser-react/package.json b/packages/parser-react/package.json
index 298bd90..9e462fd 100644
--- a/packages/parser-react/package.json
+++ b/packages/parser-react/package.json
@@ -1,6 +1,6 @@
{
"name": "@coderadar/parser-react",
- "version": "0.4.0",
+ "version": "0.4.1",
"private": true,
"description": "React/TSX parser for CodeRadar — extracts components, hooks, data sources, state, and events into a lineage graph. Bundled into the published `ui-lineage` package.",
"license": "MIT",
diff --git a/packages/parser-react/src/entities.test.ts b/packages/parser-react/src/entities.test.ts
new file mode 100644
index 0000000..cded1a0
--- /dev/null
+++ b/packages/parser-react/src/entities.test.ts
@@ -0,0 +1,73 @@
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+import { matchComponentsByText } from "@coderadar/core";
+import { describe, expect, it } from "vitest";
+
+import { decodeEntities } from "./entities.js";
+import { scanReact } from "./scan.js";
+
+const fixtures = path.resolve(
+ path.dirname(fileURLToPath(import.meta.url)),
+ "../../../eval/fixtures",
+);
+
+describe("decodeEntities", () => {
+ it("decodes named entities React resolves", () => {
+ expect(decodeEntities(" ")).toBe(" ");
+ expect(decodeEntities("a & b")).toBe("a & b");
+ expect(decodeEntities("<tag>")).toBe("");
+ expect(decodeEntities(""q"")).toBe('"q"');
+ expect(decodeEntities("·›")).toBe("·›");
+ expect(decodeEntities("café")).toBe("café");
+ });
+
+ it("decodes decimal and hex numeric entities", () => {
+ expect(decodeEntities(""")).toBe('"');
+ expect(decodeEntities(""")).toBe('"');
+ expect(decodeEntities(""")).toBe('"');
+ expect(decodeEntities("é")).toBe("é");
+ });
+
+ it("leaves unknown names and out-of-range code points untouched", () => {
+ expect(decodeEntities("&foobar;")).toBe("&foobar;");
+ expect(decodeEntities("")).toBe("");
+ // Double-encoded text stays literal — only one decoding pass, no `"`.
+ expect(decodeEntities(""")).toBe(""");
+ });
+
+ it("preserves surrounding text and mixed content", () => {
+ expect(decodeEntities("Hello World")).toBe("Hello World");
+ expect(decodeEntities("no entities here")).toBe("no entities here");
+ });
+});
+
+describe("html-entity rendered text (a16 fixture)", () => {
+ const graph = scanReact({ root: path.join(fixtures, "a16-html-entities", "app") });
+
+ it("leaves an entity-only component with no discriminating match target", () => {
+ const node = graph.nodes.find((n) => n.kind === "component" && n.name === "EntitySpacer");
+ if (node?.kind !== "component") throw new Error("EntitySpacer not found");
+ // Decoded entities are whitespace/punctuation only — nothing survives
+ // normalization as a token, so ` ` never leaks a "nbsp" target.
+ expect(node.renderedText.every((e) => !/[a-z0-9]/i.test(e.text))).toBe(true);
+ });
+
+ it("declines junk tokens the raw entities would have produced", () => {
+ // Named-entity junk ("nbsp"), numeric-entity junk ("34"), and gibberish
+ // that shares those digits all decline instead of matching EntitySpacer.
+ for (const term of ["nbsp", "34", "zzqwxnomatch12345"]) {
+ expect(matchComponentsByText(graph, [term]).status).toBe("declined");
+ }
+ });
+
+ it("still matches a real component and isn't poisoned by digit-sharing gibberish", () => {
+ const real = matchComponentsByText(graph, ["Storage quota exceeded"]);
+ expect(real.status).toBe("ok");
+ expect(real.candidates[0]?.value.component.name).toBe("QuotaNotice");
+
+ const mixed = matchComponentsByText(graph, ["zzqwxnomatch12345", "Storage quota exceeded"]);
+ expect(mixed.status).toBe("ok");
+ expect(mixed.candidates[0]?.value.component.name).toBe("QuotaNotice");
+ });
+});
diff --git a/packages/parser-react/src/entities.ts b/packages/parser-react/src/entities.ts
new file mode 100644
index 0000000..79948d1
--- /dev/null
+++ b/packages/parser-react/src/entities.ts
@@ -0,0 +1,82 @@
+/**
+ * HTML-entity decoding for extracted rendered text (failure mode A16).
+ *
+ * JSX text and quoted attribute values are HTML-decoded by React at render
+ * time — `` renders a non-breaking space, `