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("&#34;")).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, `>` a + * `>`, `"` a double quote — but ts-morph hands us the raw source, so + * without decoding the entity survives into `renderedText` and normalizes to a + * junk token: ` `→"nbsp", `>`→"gt", `"`→"34". Those spurious tokens + * create false matches; a numeric entity like `"` even lets a gibberish + * query that happens to share its digits ("zzqwxnomatch12345") match. Decoding + * restores the character React actually renders, which then normalizes away as + * punctuation/whitespace (or becomes real letters for accented entities), so + * entity-only text stops acting as a match target (self-found on Grafana's + * frontend, 0.4.0 — 26 ` `, 4 `"`, plus `"`/`>`/`<`/ + * `·`/`›`). + * + * Only the entities React decodes are handled. Unknown names are left verbatim + * (React renders `&foobar;` literally), which is also the safe choice — we + * never guess. + */ + +const SPACE = " "; + +/** + * Named HTML entities React decodes, mapped to their character. Markup, + * whitespace, punctuation, symbols and currency all decode to characters the + * normalizer strips (so entity-only text yields no target); accented Latin-1 + * letters decode to real letters, which is faithful rendered signal. + */ +const NAMED: Record = { + // Markup + whitespace. Spaces collapse to a plain ASCII space (what OCR reads + // off a screenshot); the invisible soft hyphen decodes to nothing. + amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", + nbsp: SPACE, ensp: SPACE, emsp: SPACE, thinsp: SPACE, shy: "", + // Punctuation + hellip: "…", mdash: "—", ndash: "–", minus: "−", + lsquo: "‘", rsquo: "’", sbquo: "‚", + ldquo: "“", rdquo: "”", bdquo: "„", + laquo: "«", raquo: "»", lsaquo: "‹", rsaquo: "›", + middot: "·", bull: "•", dagger: "†", Dagger: "‡", + sect: "§", para: "¶", prime: "′", Prime: "″", + // Symbols + currency + fractions + copy: "©", reg: "®", trade: "™", deg: "°", + plusmn: "±", times: "×", divide: "÷", + frac12: "½", frac14: "¼", frac34: "¾", + euro: "€", pound: "£", cent: "¢", yen: "¥", + // Common accented Latin-1 letters (decode to real match signal) + agrave: "à", aacute: "á", acirc: "â", atilde: "ã", + auml: "ä", aring: "å", aelig: "æ", ccedil: "ç", + egrave: "è", eacute: "é", ecirc: "ê", euml: "ë", + igrave: "ì", iacute: "í", icirc: "î", iuml: "ï", + ntilde: "ñ", ograve: "ò", oacute: "ó", ocirc: "ô", + otilde: "õ", ouml: "ö", oslash: "ø", ugrave: "ù", + uacute: "ú", ucirc: "û", uuml: "ü", yuml: "ÿ", szlig: "ß", +}; + +const ENTITY = /&(#[Xx][0-9A-Fa-f]+|#[0-9]+|[A-Za-z][A-Za-z0-9]*);/g; + +/** A code point safe to materialize: in range and not a lone surrogate. */ +function fromCodePoint(code: number): string | null { + if (!Number.isInteger(code) || code < 0 || code > 0x10ffff) return null; + if (code >= 0xd800 && code <= 0xdfff) return null; + return String.fromCodePoint(code); +} + +/** + * Decode the HTML entities React resolves at render time. Numeric entities + * (decimal `"`, hex `"`) decode generically; named entities decode + * from {@link NAMED}. Unrecognized names and out-of-range code points are left + * untouched, matching React's literal rendering. + */ +export function decodeEntities(input: string): string { + if (!input.includes("&")) return input; + return input.replace(ENTITY, (whole, body: string) => { + if (body.charCodeAt(0) === 35 /* # */) { + const isHex = body.charCodeAt(1) === 88 || body.charCodeAt(1) === 120; /* X | x */ + const code = Number.parseInt(body.slice(isHex ? 2 : 1), isHex ? 16 : 10); + return fromCodePoint(code) ?? whole; + } + return NAMED[body] ?? whole; + }); +} diff --git a/packages/parser-react/src/scan.ts b/packages/parser-react/src/scan.ts index 8654f64..be5e6d5 100644 --- a/packages/parser-react/src/scan.ts +++ b/packages/parser-react/src/scan.ts @@ -30,6 +30,7 @@ import { } from "ts-morph"; import { fetchMethod, resolveEndpoint, type ResolvedEndpoint, resolveStringValue } from "./endpoint.js"; +import { decodeEntities } from "./entities.js"; import { i18nRenderedText, type I18nOptions, loadLocaleTable, type LocaleTable } from "./i18n.js"; import { linkOpenApiResponses, loadOpenApi, responseFromCall } from "./response.js"; import { detectRoutes } from "./routes.js"; @@ -270,7 +271,7 @@ export function scanReact(options: ScanOptions): LineageGraph { version: 2, root, generatedAt: new Date().toISOString(), - generator: "ui-lineage@0.4.0", + generator: "ui-lineage@0.4.1", nodes: [...nodes.values()], edges, }; @@ -368,8 +369,12 @@ function extractRenderedText(fn: Node): RenderedText[] { entries.set(`${entry.source}:${entry.text}:${entry.branch ?? ""}`, entry); }; + // JSX text and quoted attribute values are HTML-decoded by React at render + // time, so decode entities here too (failure mode A16) — otherwise ` `, + // `>`, `"` survive as junk tokens ("nbsp", "gt", "34"). JS string and + // template literals below are NOT JSX-decoded, so they stay untouched. for (const jsxText of fn.getDescendantsOfKind(SyntaxKind.JsxText)) { - const text = jsxText.getText().replace(/\s+/g, " ").trim(); + const text = decodeEntities(jsxText.getText()).replace(/\s+/g, " ").trim(); if (text.length === 0) continue; add({ text, source: "jsx", ...branchTag(jsxText, fn) }); } @@ -378,7 +383,7 @@ function extractRenderedText(fn: Node): RenderedText[] { if (!TEXT_ATTRIBUTES.has(attr.getNameNode().getText())) continue; const init = attr.getInitializer(); if (init !== undefined && Node.isStringLiteral(init)) { - const text = init.getLiteralValue().trim(); + const text = decodeEntities(init.getLiteralValue()).trim(); if (text.length > 0) add({ text, source: "attribute", ...branchTag(attr, fn) }); } } diff --git a/packages/vision/package.json b/packages/vision/package.json index f2b30c8..172660b 100644 --- a/packages/vision/package.json +++ b/packages/vision/package.json @@ -1,6 +1,6 @@ { "name": "@coderadar/vision", - "version": "0.4.0", + "version": "0.4.1", "private": true, "description": "Screenshot → { terms, structure, annotations } vision adapter for ui-lineage. Images are processed in memory and never persisted (G7). Bundled into the published ui-lineage package.", "license": "MIT",