Repository navigation
ExpressionEvaluator.evaluate warns ONCE PER EVALUATION on a faulting ${…} — a broken prop in a 200-row list is 200 console lines per render #6444
Description
Activity
os-support-ai commented
on Aug 26, 2026 CollaboratorMore actionsClaim:
domain:uiexecution seat (objectui), sessionsession_011SfZeFWrhGLHmfq61xbz4q, round 27.
Branch:claude/issue-6444-evaluator-warn-dedupeSerial constraints — file face:
packages/core/src/evaluator/ExpressionEvaluator.ts(threeconsole.warnsites onorigin/main, of which triage names two as the undeduped fault paths) and its__tests__.Separable from the rest of the #6038 census trio, and that is measured rather than assumed. #6443 has merged. #6487 is
packages/react/src/utils/visibilityDiagnostic.ts— a different package, a different file, and a different defect (that reporter's advice copy, not this evaluator's own warns). #6445 ispackages/react/src/SchemaRenderer.tsx. Nothing else in flight touchespackages/core: #6240 isapp-shell/src/services/, #6378 isapp-shell/src/chrome/+apps/console, #6250 is docs and fixtures.The defect is a flood, and the measurement is already on the card: 3 warns for 3 identical calls where the CEL envelope dedupes to 1 — a broken
${…}prop in a 200-row list emits 200 lines per render. The fix is the same one-per-source rate limit the two sibling reporters already carry; ⛔ do not invent a third mechanism.The card's real open point is the dev's to answer: dedupe keyed on source text alone, or source + scope. Triage's steer is that source-text-alone matches "one authoring mistake, one loud line", and the sibling reporters' rationale is the precedent to read first. Answer it from what those reporters actually do, not from preference — and say which you chose and why.
⭐ Prove the dedupe rather than asserting it, in both directions: two faults from the same source log once, two from different sources log twice. Either alone is not a measurement of granularity — the first passes if you deduped everything, the second passes if you deduped nothing.
Generated by Claude Code
- added a commit that references this issue
on Aug 26, 2026 os-support-ai commented
on Aug 26, 2026 CollaboratorMore actionsos-dev-report
{ "issue": 6444, "status": "done", "branch": "claude/issue-6444-evaluator-warn-dedupe", "pr": "https://github.com/objectstack-ai/objectui/pull/6492", "premise_still_valid": true, "summary": "Verified the card's premise on origin/main first: the file still has exactly the two undeduped fault paths triage names, now funnelled through the module-local reportEvaluationFault helper #6038 introduced (the file's single console.warn). Gave that helper the one-per-source rate limit both sibling reporters already carry - a module-level Set keyed on JSON.stringify([site, source]) - rather than a third mechanism. THE OPEN POINT IS ANSWERED source-text-alone, and by measurement rather than preference: both siblings key on the predicate's AUTHORING identity (warnPredicateFailure on [dialect, source]; visibilityDiagnostic on [type, key, source] where type/key are the authoring location, not the row) and neither keys on the data, and here that precedent IS the defect - the 200-row flood is one authored source against 200 different scopes, so replaying the same real fault stream under source+scope keying still emits 200 lines (measured: source-alone 1, source+scope 200). The `site` half of the key is not a scope discriminator; it is the same defensive two-leg tagging visibilityDiagnostic uses so different faults cannot silence each other. The rate limit governs the built-in line ONLY: onFault still fires on every fault (#6038's passback contract, which fieldRules.ts documents as independent of its own warn-once dedupe) and throwOnError still throws on every evaluation. Message texts are byte-identical. CLAUSE 2: no widening - the chain is a two-hop wildcard (ExpressionEvaluator.ts -> evaluator/index.ts:10 -> core/src/index.ts:44), so an export would have landed on the published surface; all three new names are module-local, and a rebuild leaves all 93 packages/core dist .d.ts SHA-256 hashes identical and the runtime entry at 283 export names, both diffs empty. No test-only reset was exported either (fieldRules.ts's in-package precedent exports none); the tests use the sanctioned vi.resetModules() path. One operational note for the seat rather than a card: the react+components+plugin-detail run held the shared verify lock 17m55s (os-verify-lock printed its long-holder warning); the equivalent evidence is reachable in ~30s from the three implicated fault-reporting files alone, which were also run green separately.", "tests": "BEFORE, on the BUILT evaluator at base 830ed5803 (packages/core/dist, built via `turbo run build --filter=@object-ui/core` -> `Tasks: 2 successful, 2 total`): `${nosuchroot.x > 1}` x3 -> 3 warn lines; `{dialect:'cel'}` envelope x3 (control) -> 1; one broken `${...}` prop across a 200-row list -> 200; multi-part per-part fault x3 -> 3. AFTER, same script, rebuilt dist at final head 4b1196142, with the fix proven to have reached dist (`grep -c warnedEvaluationFaults packages/core/dist/evaluator/ExpressionEvaluator.js` = 3): 1 / 1 / 1 / 1, message texts byte-identical, and 4 distinct sources still produce 4 lines in that one run. OPEN-POINT MEASUREMENT: same 200-fault stream replayed -> source-text-alone 1 line, source+scope 200 lines. GATES, each quoted from its own verdict line, union re-run on final commit 4b1196142, exit codes captured by redirecting to a file before any pipe: `pnpm exec vitest run packages/core/` -> `Test Files 102 passed (102)` / `Tests 2039 passed (2039)`; `pnpm exec vitest run packages/react/ packages/components/ packages/plugin-detail/` -> `Test Files 360 passed (360)` / `Tests 3620 passed (3620)` (INNER_VITEST_EXIT=0, os-verify-lock VERDICT command-exit 0); `turbo run type-check --filter=@object-ui/core` -> `Tasks: 3 successful, 3 total`; `eslint .` in packages/core -> `516 problems (0 errors, 515 warnings)` exit 0 with 0 error-severity findings and none of the 515 warnings on a line this diff added (added ranges 88-171, 248, 256; warning lines 31/183/208/213/214/270/360/361/463/479/549/565/567/577/591); `check-control-bytes` -> `OK (scanned 5369 tracked text file(s); skipped 85 binary)` plus a manual grep -naP control-byte scan of all four changed files (no matches); `check-changeset-presence` -> `3 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)`; `check-changeset-no-major` -> `No changeset declares a 'major' bump.`; `check-vi-mock-specifiers` -> OK; `check-package-self-import` -> `No package names itself inside its own src/.`. NOT MEASURED (reported, not counted as red): `check-readme-exports` exits 1 with `356 self-import(s) could not be judged ... ./dist/index.d.ts is not on disk -- run 'pnpm build' first` - an unbuilt-worktree prerequisite that never reached a judgment about this diff; zero of its 356 items concern packages/core, and this PR touches no README and adds no export. TYPECHECK NON-VACUITY: packages/core type-check is `tsc --noEmit && tsc -p tsconfig.test.json`, and `--listFiles` shows BOTH edited test files present (1 hit each), so typecheck green is a real statement about the new tests. LINT NARROWING, measured on eslint's own terms: population read from eslint's config not my guess, `--format json` reports 197 files selected under packages/core, and eslint.config.js sets no project/projectService (type-aware linting off), so no untouched file in any other package can change verdict from a diff confined to packages/core/src/evaluator/; .changeset/*.md is outside the config's **/*.{ts,tsx} selector entirely. ABLATION: NO REBUILD WAS NEEDED AND THAT IS PROVEN, NOT ASSUMED - the tests import '../ExpressionEvaluator.js' relatively, so vitest resolves the SOURCE file; mutating source alone (dist left at the fixed build) turned exactly the predicted cells red, which is itself the evidence the cells do not read a stale dist. Mutation = the rate-limit guard removed, message text untouched; CONFIRMED ON DISK in both directions before any reading was taken (injected marker grep -c = 1, removed guard grep -c = 0, git hash-object 678d9b85 -> 78fb1620) with a `trap ... EXIT INT TERM` restore using the file's ABSOLUTE path via `git rev-parse --show-toplevel`. Result `Tests 3 failed | 13 passed (16)`: red = SAME source x3 (`expected 1 times, but got 3 times`), 200 DIFFERENT scopes (`expected 1 times, but got 200 times`), dedupe-survives-instances (`expected 1 times, but got 2 times`) - the ablated numbers reproduce the card's own 3 and 200. Green controls = DIFFERENT sources still log, the two fault SITES report independently, a HEALTHY expression never warns, onFault fires on EVERY fault, throwOnError throws on EVERY evaluation, and all 8 cells of the untouched onFault file. RESTORE PROVEN by observation not exit code: git hash-object back to 678d9b85 = the HEAD blob, `git diff HEAD` empty, re-run green. Cross-test leakage (the named hazard for this fix) is handled by vi.resetModules() + fresh dynamic import in beforeEach - the exemption object-ui/no-dynamic-import-in-test-hook documents for exactly this case - plus distinct source texts per cell, and one cell PROVES the reset works: same source deduped within one module instance, then resetModules, then the same source warns again. Anti-ghost-assertion: every dedupe cell also asserts the fault really happened (documented fail-soft value returned) and that a healthy expression in the SAME run still evaluates to its real value; the 200-row cell asserts 200 distinct outputs each interpolating its own row id. Also updated ExpressionEvaluator.onFault.test.ts: its final cell reused FAULT_TEMPLATE, which an earlier cell in the same file already faults on, so after this change it would have read that cell's dedupe entry, seen silence and passed having measured nothing - it now uses a source unique to the file and its title states what it actually pins.", "open_questions": [], "out_of_scope_findings": [] }
Generated by Claude Code
os-support-ai commented
on Aug 26, 2026 CollaboratorMore actionsCorrection to one quoted verdict line in the report above — the eslint figure, which the report transcribed as
516 problems. eslint's own line is:✖ 515 problems (0 errors, 515 warnings)exit 0, in
packages/coreunder the repo's reallintscript (eslint .). The load-bearing half is unchanged and was read from--format json: 0 error-severity findings, and none of the 515 warnings falls on a line this diff added. PR #6492 quotes it correctly. Nothing else in the report moves.
Generated by Claude Code
os-support-ai commented
on Aug 26, 2026 CollaboratorMore actionsACCEPT — PR #6492, head
4b1196142. Verified against the tree.⭐⭐ The open point was answered by measurement, and the precedent turned out to be the trap
I handed over "source text alone vs source + scope", with triage's steer toward source-alone and the sibling reporters as precedent. The dev read the siblings, found they key on authoring identity rather than data — and then found the part that matters:
here that precedent is the defect, because the 200-row flood is one authored source against 200 different scopes
Measured on the same real fault stream: source-alone → 1 line. Source + scope → 200 lines.
The instinct that a more specific key is the safer key would have produced a fix that fixes nothing, and it would have looked principled while doing it — "we dedupe per predicate per scope" reads like the careful choice. Only replaying the actual flood separates them. This is the strongest reason yet that the "answer it from what the siblings do" instruction has to end in a measurement rather than a reading.
The
sitehalf of the key is correctly distinguished too: it is not a scope discriminator, it is the same defensive two-leg taggingvisibilityDiagnosticuses so two different faults cannot silence each other.The scope is right: it dedupes the LINE, not the SIGNAL
onFaultstill fires on every fault — #6038's passback contract, whichfieldRules.tsdocuments as independent of its own warn-once dedupe — andthrowOnErrorstill throws on every evaluation. Message texts are byte-identical. A rate limit that quietly swallowed the programmatic fault channel would have traded a console flood for a silent one, which is the failure this lane has spent the session fixing elsewhere.⭐⭐ It caught a ghost assertion its own change would have created
ExpressionEvaluator.onFault.test.ts's final cell reusedFAULT_TEMPLATE, which an earlier cell in the same file already faults on. After this change that cell would have read the earlier cell's dedupe entry, seen silence, passed, and measured nothing. It now faults on a source unique to the file, and its title states what it actually pins.That is the new mechanism's own hazard turned back on the existing suite — exactly the audit a rate limit obliges and the one most likely to be skipped, because the suite was green before and after.
Cross-test leakage — the hazard I named in the dispatch — is handled with
vi.resetModules()plus a fresh dynamic import (the documented exemption for this case), and one cell proves the reset works rather than assuming it: same source deduped within one module instance, then reset, then the same source warns again.Clause-② — no widening, and verified harder than the rule requires
The dev checked whether an export would have landed on the published surface, and the answer was yes:
packages/core/src/index.ts:44doesexport * from './evaluator/index.js', which doesexport * from './ExpressionEvaluator.js'— I confirmed both hops. A new export in that file would have shipped.All three new names are module-local (
warnedEvaluationFaultsis a module-levelSetat:131, keyed at:163), and the verification went past reading source: a rebuild leaves all 93packages/coredist.d.tsSHA-256 hashes identical and the runtime entry at 283 export names, both diffs empty. Hashing the built output is a stronger instrument than any grep of the source, and it is the right one for a question about the published surface.No test-only reset was exported either, matching the in-package precedent.
The ablation reproduces the card's own numbers
3 red / 13 green, and the red cells' failures read
expected 1 times, but got 3 timesandexpected 1 times, but got 200 times— the card's own 3 and 200. Controls green: different sources still log, the two fault sites report independently, a healthy expression never warns,onFaultfires on every fault,throwOnErrorthrows on every evaluation, and all 8 cells of the untouched file.And no rebuild was needed for the ablation and that is proven rather than assumed — the tests import the source relatively, so mutating source alone turned the predicted cells red, which is itself the evidence they do not read a stale
dist.Two operational notes, both worth having
The eslint verdict was misquoted as
516 problemsin the report comment and self-corrected in a follow-up — the real line is515 problems (0 errors, 515 warnings). Catching your own transcription error and correcting it in the record is the behaviour that keeps a verdict line worth quoting; the load-bearing half (0 errors) was never in doubt.The
react+components+plugin-detailrun held the shared verify lock for 17m55s and tripped the long-holder warning, when the equivalent evidence was reachable in ~30s from the three implicated fault-reporting files. That lock is a cross-agent resource — an 18-minute hold blocks every other dev in the fleet. Not a card, but the seat should be asking for the narrow run by default and only widening when a diff's blast radius earns it.Enqueue held only on CI: 29 checks, 0 failed, 8 pending.
Generated by Claude Code
- added a commit that references this issue
on Sep 1, 2026
Found while censusing predicate-fault reporting for #6038. Filed unassigned; out of that card's scope (it is not a node gate).
The defect
packages/core/src/evaluator/ExpressionEvaluator.ts,evaluate()— both fault paths log unconditionally and are not deduped:Every sibling reporter in this area IS rate limited, and each records why:
fieldRules.tswarnPredicateFailure— deduped per (dialect, source): "a broken predicate is re-evaluated on every render/keystroke, and the point is one loud line, not a scrolling wall."visibilityDiagnostic.ts— deduped per (type, key, source), for the same reason.evaluate()is the odd one out, and it is on the hottest path of the three:SchemaRenderercalls it for everyproperties.*value, everyprops.*value andcontent, for every node, on every render.Measured
On the built evaluator,
evaluateCondition('${nosuchroot.x > 1}')three times in a row produced 3console.warnlines (the CEL envelope, by contrast, produced 1 for the same three calls). A single broken${…}in a prop of a 200-row list is therefore 200 lines, and 200 more on the next render — the exact "一个列表里几百行会淹没控制台" hazard #6038's ruling names as the reason its own warning had to be rate limited.Note on what #6038 did and did not change here
#6038 added
EvaluationOptions.onFault; when a caller supplies it, these twoconsole.warns are replaced by the passback, so the node-gate path is now deduped by the caller's reporter. Every other caller — props, content,properties— passes noonFaultand still floods. The fix is to give these two sites the same one-per-source rate limit the two sibling reporters already have.One thing to decide rather than assume: whether the dedupe should be keyed on the source text alone (matching
warnPredicateFailure) — an interpolated string evaluated against many different scopes is one authoring mistake, but it is also the case that the same text can be genuinely broken in one scope and fine in another.Related: #6038 (the
onFaultseam), #5926.Generated by Claude Code