[GH-ISSUE #3376] [Bug] Missing or malformed accessibility inventories are reported as complete empty trees #1244

Closed
opened 2026-10-16 21:03:15 +00:00 by bsakaguchi · 4 comments
Owner

Originally created by @coygeek on GitHub (Oct 14, 2026).
Original GitHub issue: https://github.com/morluto/rea/issues/3376

Before submitting

  • I searched the open and closed issues for a related report.
  • I removed credentials, tokens, private paths, and sensitive target data.

Area

Process capture or runtime observation

Observed behavior

inspect-web-page --include-accessibility-text silently converts missing or malformed CDP accessibility inventories into an empty tree with selected text reported as included. Comparing that capture with a valid empty accessibility tree reports the accessibility dimension as unchanged. A producer omission therefore becomes evidence of equality.

The valid nodes: [], missing nodes, and malformed nodes: [null] inspections all exited with code 0 and returned the same accessibility result. For the missing and malformed cases, both unavailable_sections and truncated_sections were empty, and no accessibility exclusion appeared. The comparison also exited with code 0 and reported accessibility unchanged with zero changes and no reason.

A separate executed mixed reply containing a null entry and one valid button retained the button, reported a total of one, and still supplied no accessibility omission marker. This reproduction establishes production CLI handling of simulated producer replies, not that a particular Chrome release naturally emits these malformed replies.

Reproduction

  1. From a clean checkout, select the source baseline and install the locked dependencies, then build the runtime. The observed reproduction used dependencies and a compiled runtime that were already present; installation and build below are setup instructions.
git checkout 8dde6370d0809ad04cda3342d4e6f290f48aa063
npm ci
npm run build:cached
  1. Run the following inline program from the repository root. It starts one loopback HTTP/WebSocket CDP fixture, calls the compiled CLI for a valid empty inventory, a missing inventory, and an inventory containing a malformed entry, then compares the first two captures. All other replies are unchanged. The program creates every capture input file, isolates REA's home directory, and removes its temporary files and server after use.
node --input-type=module <<'JS'
import {createServer} from 'node:http';
import {spawn} from 'node:child_process';
import {createRequire} from 'node:module';
import {mkdtemp, mkdir, writeFile, rm} from 'node:fs/promises';
import {tmpdir} from 'node:os';
import {join, resolve} from 'node:path';
const {WebSocketServer} = createRequire(resolve('package.json'))('ws');
const root=await mkdtemp(join(tmpdir(),'rea-inventory-'));
await mkdir(join(root,'home'));
let endpoint, variant='valid';
const url=()=>`${endpoint}/page`;
const target=()=>({id:'page',type:'page',title:'Fixture',url:url(),attached:false});
const server=createServer((req,res)=>{
 res.setHeader('Content-Type','application/json');
 res.end(JSON.stringify(req.url==='/json/list'?[target()]:{
  Browser:'FixtureChrome/1.0','Protocol-Version':'1.3','User-Agent':'FixtureChrome',
  'V8-Version':'1.0','WebKit-Version':'fixture',
  webSocketDebuggerUrl:endpoint.replace('http:','ws:')+'/devtools/browser/fixture'
 }));
});
const sockets=new WebSocketServer({server});
sockets.on('connection',socket=>socket.on('message',raw=>{
 const c=JSON.parse(raw);let result={};
 switch(c.method){
  case 'Target.attachToTarget':result={sessionId:'session'};break;
  case 'Page.getFrameTree':result={frameTree:{frame:{id:'frame',loaderId:'loader',url:url()}}};break;
  case 'Page.getResourceTree':result={frameTree:{frame:{id:'frame',url:url()},resources:[]}};break;
  case 'DOMSnapshot.captureSnapshot':result={strings:[url(),'#document','HTML'],documents:[{documentURL:0,baseURL:0,nodes:{nodeType:[9,1],nodeName:[1,2],nodeValue:[-1,-1],parentIndex:[-1,0],attributes:[[],[]]}}]};break;
  case 'Accessibility.getFullAXTree':result=variant==='missing'?{}:variant==='malformed'?{nodes:[null]}:{nodes:[]};break;
  case 'Target.getTargets':result={targetInfos:[]};break;
  case 'Storage.getUsageAndQuota':result={usage:0,quota:1024,usageBreakdown:[]};break;
 }
 socket.send(JSON.stringify({id:c.id,...(c.sessionId?{sessionId:c.sessionId}:{}),result}));
}));
await new Promise(r=>server.listen(0,'127.0.0.1',r));
endpoint=`http://127.0.0.1:${server.address().port}`;
const cli=args=>new Promise((resolveResult,reject)=>{
 const child=spawn(process.execPath,['scripts/rea.mjs',...args],{env:{...process.env,HOME:join(root,'home')}});
 let output='',errors='';child.stdout.on('data',c=>output+=c);child.stderr.on('data',c=>errors+=c);
 child.on('error',reject);child.on('close',code=>resolveResult({code,output,errors}));
});
try{
 for(const v of ['valid','missing','malformed']){
  variant=v;
  const r=await cli(['inspect-web-page',endpoint,'page','--observation-ms','0','--include-accessibility-text','--json']);
  if(r.code!==0)throw new Error(r.output+r.errors);
  const n=JSON.parse(r.output).normalized_result;
  await writeFile(join(root,`${v}.json`),JSON.stringify({inspection:n}));
  console.log(JSON.stringify({variant:v,exit_code:r.code,accessibility:n.accessibility,workers:n.workers,unavailable:n.completeness.unavailable_sections,truncated:n.completeness.truncated_sections,excluded:n.completeness.excluded}));
 }
 const comparison=await cli(['compare-web-captures',join(root,'valid.json'),join(root,'missing.json'),'--json']);
 if(comparison.code!==0)throw new Error(comparison.output+comparison.errors);
 console.log(JSON.stringify({comparison_exit_code:comparison.code,accessibility_comparison:JSON.parse(comparison.output).normalized_result.dimensions.accessibility}));
}finally{
 for(const socket of sockets.clients)socket.terminate();
 await new Promise(r=>sockets.close(r));await new Promise(r=>server.close(r));
 await rm(root,{recursive:true,force:true});
}
JS
  1. Inspect the printed accessibility results and completeness fields for each variant, and the accessibility comparison result. The missing and malformed cases currently match the valid empty tree, and the valid-versus-missing comparison reports unchanged.

Expected behavior

A valid nodes: [] reply can establish an empty accessibility inventory. A successful CDP reply that omits nodes, or reports nodes: [null], cannot establish an empty tree. REA should reject those malformed observations or record their unavailable/incomplete accessibility coverage. A comparison involving that unavailable inventory must not report accessibility as unchanged. Usable nodes in a partly malformed reply should retain their own facts without establishing full coverage.

docs/browser-observation.md:267 and docs/browser-observation.md:297 require omitted or invalid observations to carry completeness information. The passive comparison contract under "Shipped surfaces" at docs/browser-observation.md:22 says missing or truncated evidence is never treated as equivalence. docs/tool-design.md:55 requires missing coverage to remain unknown rather than absence.

Impact and scope

P2, supported. Malformed producer accessibility data causes a successful capture and a false unchanged result for a selected analysis dimension. The observed reach is bounded to the malformed reply cases; normal empty inventories remain valid. The failure affects the integrity of the reported accessibility coverage and comparison result.

Workaround status is unverified. Recapturing may obtain a valid provider inventory, but no caller option exposes or validates the discarded reply. A practical way to distinguish a truly empty tree from this malformed capture was not established.

REA version

6.4.0 (main at 8dde6370d0)

Runtime and platform

Node.js v26.10.0, macOS 27.0.1 arm64. Dependencies and the compiled runtime were already present for the observed reproduction.

Target and artifact context

A simulated loopback HTTP/WebSocket CDP endpoint advertising FixtureChrome/1.0 and protocol version 1.3, one page target, and otherwise unchanged protocol replies. The inline program writes normalized page inspections in { "inspection": ... } JSON files for the public comparison CLI. No installed Chrome release was exercised.

Interface

CLI

Earliest failure stage

Capture or observation

Relevant output or evidence

All three inspections exited with code 0 and returned:
{"total_nodes":0,"text_capture":{"status":"included","retained_bytes":0,"excluded_fields":0},"nodes":[]}

Missing and malformed inventories:
unavailable_sections=[]
truncated_sections=[]
No accessibility exclusion appeared.

Valid empty inventory versus missing inventory, comparison exit code 0:
{"total_changes":0,"changes":[],"status":"unchanged","reason":null}

The separate executed mixed reply retained one valid button and reported a total of one without an accessibility omission marker. The observed reproduction was complete for the simulated producer replies and public CLI path.

Typed outcome or recovery expected

Run the same valid, missing, and malformed reply cases. The valid empty tree should remain an empty complete observation. The missing and malformed inventories must be rejected or explicitly incomplete, and comparison against a valid empty tree must not claim accessibility equality. Include a partially malformed array containing a valid button to verify that surviving facts do not erase the omitted entry's coverage gap.

Analysis or suspected cause

Source-derived reasoning: src/browser/CdpCaptureDocuments.ts:465 drops missing/non-record nodes before calculating the returned total, and src/browser/CdpCaptureDocuments.ts:498 checks only surviving child references for incompleteness. src/domain/webCaptureDiff.ts:689 consequently admits the empty projection as comparable. This reasoning is consistent with the executed command returning identical complete accessibility projections for different producer replies and an unchanged accessibility comparison.

Additional context

Related completed issue #377 introduced accessibility and storage comparison and required missing/truncated evidence never to count as equality; it did not describe this malformed accessibility inventory trigger.

Related completed issue #2660 concerns malformed cache-name inventories producing complete storage fingerprints; this failure concerns Accessibility.getFullAXTree and the accessibility dimension.

Related completed issue #2553 concerns invalid aligned DOM arrays shifting facts between nodes; this failure concerns missing or discarded accessibility inventory entries becoming complete empty trees.

Related completed issue #2556 concerns malformed Electron child-frame IDs being classified as scope exclusions; this failure concerns omitted accessibility coverage.

Related open issue #3172 concerns unavailable redirect-hop response metadata being treated as known network evidence; this failure concerns malformed accessibility inventories.

Related completed issue #2169 concerns mismatched scenario event-family selections producing false equality; this failure occurs during passive accessibility capture despite identical selected options.

Related open issue #3173 concerns missing network-content recovery guidance alongside valid console differences; this failure concerns false accessibility equality after malformed producer replies.

Related completed issue #1830 concerns unavailable final-response bytes being treated as a proven network change; this failure concerns missing accessibility enumeration being treated as a complete empty tree.

Originally created by @coygeek on GitHub (Oct 14, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/3376 ### Before submitting - [x] I searched the open and closed issues for a related report. - [x] I removed credentials, tokens, private paths, and sensitive target data. ### Area Process capture or runtime observation ### Observed behavior `inspect-web-page --include-accessibility-text` silently converts missing or malformed CDP accessibility inventories into an empty tree with selected text reported as included. Comparing that capture with a valid empty accessibility tree reports the accessibility dimension as unchanged. A producer omission therefore becomes evidence of equality. The valid `nodes: []`, missing `nodes`, and malformed `nodes: [null]` inspections all exited with code 0 and returned the same accessibility result. For the missing and malformed cases, both `unavailable_sections` and `truncated_sections` were empty, and no accessibility exclusion appeared. The comparison also exited with code 0 and reported accessibility unchanged with zero changes and no reason. A separate executed mixed reply containing a null entry and one valid button retained the button, reported a total of one, and still supplied no accessibility omission marker. This reproduction establishes production CLI handling of simulated producer replies, not that a particular Chrome release naturally emits these malformed replies. ### Reproduction 1. From a clean checkout, select the source baseline and install the locked dependencies, then build the runtime. The observed reproduction used dependencies and a compiled runtime that were already present; installation and build below are setup instructions. ```sh git checkout 8dde6370d0809ad04cda3342d4e6f290f48aa063 npm ci npm run build:cached ``` 2. Run the following inline program from the repository root. It starts one loopback HTTP/WebSocket CDP fixture, calls the compiled CLI for a valid empty inventory, a missing inventory, and an inventory containing a malformed entry, then compares the first two captures. All other replies are unchanged. The program creates every capture input file, isolates REA's home directory, and removes its temporary files and server after use. ```sh node --input-type=module <<'JS' import {createServer} from 'node:http'; import {spawn} from 'node:child_process'; import {createRequire} from 'node:module'; import {mkdtemp, mkdir, writeFile, rm} from 'node:fs/promises'; import {tmpdir} from 'node:os'; import {join, resolve} from 'node:path'; const {WebSocketServer} = createRequire(resolve('package.json'))('ws'); const root=await mkdtemp(join(tmpdir(),'rea-inventory-')); await mkdir(join(root,'home')); let endpoint, variant='valid'; const url=()=>`${endpoint}/page`; const target=()=>({id:'page',type:'page',title:'Fixture',url:url(),attached:false}); const server=createServer((req,res)=>{ res.setHeader('Content-Type','application/json'); res.end(JSON.stringify(req.url==='/json/list'?[target()]:{ Browser:'FixtureChrome/1.0','Protocol-Version':'1.3','User-Agent':'FixtureChrome', 'V8-Version':'1.0','WebKit-Version':'fixture', webSocketDebuggerUrl:endpoint.replace('http:','ws:')+'/devtools/browser/fixture' })); }); const sockets=new WebSocketServer({server}); sockets.on('connection',socket=>socket.on('message',raw=>{ const c=JSON.parse(raw);let result={}; switch(c.method){ case 'Target.attachToTarget':result={sessionId:'session'};break; case 'Page.getFrameTree':result={frameTree:{frame:{id:'frame',loaderId:'loader',url:url()}}};break; case 'Page.getResourceTree':result={frameTree:{frame:{id:'frame',url:url()},resources:[]}};break; case 'DOMSnapshot.captureSnapshot':result={strings:[url(),'#document','HTML'],documents:[{documentURL:0,baseURL:0,nodes:{nodeType:[9,1],nodeName:[1,2],nodeValue:[-1,-1],parentIndex:[-1,0],attributes:[[],[]]}}]};break; case 'Accessibility.getFullAXTree':result=variant==='missing'?{}:variant==='malformed'?{nodes:[null]}:{nodes:[]};break; case 'Target.getTargets':result={targetInfos:[]};break; case 'Storage.getUsageAndQuota':result={usage:0,quota:1024,usageBreakdown:[]};break; } socket.send(JSON.stringify({id:c.id,...(c.sessionId?{sessionId:c.sessionId}:{}),result})); })); await new Promise(r=>server.listen(0,'127.0.0.1',r)); endpoint=`http://127.0.0.1:${server.address().port}`; const cli=args=>new Promise((resolveResult,reject)=>{ const child=spawn(process.execPath,['scripts/rea.mjs',...args],{env:{...process.env,HOME:join(root,'home')}}); let output='',errors='';child.stdout.on('data',c=>output+=c);child.stderr.on('data',c=>errors+=c); child.on('error',reject);child.on('close',code=>resolveResult({code,output,errors})); }); try{ for(const v of ['valid','missing','malformed']){ variant=v; const r=await cli(['inspect-web-page',endpoint,'page','--observation-ms','0','--include-accessibility-text','--json']); if(r.code!==0)throw new Error(r.output+r.errors); const n=JSON.parse(r.output).normalized_result; await writeFile(join(root,`${v}.json`),JSON.stringify({inspection:n})); console.log(JSON.stringify({variant:v,exit_code:r.code,accessibility:n.accessibility,workers:n.workers,unavailable:n.completeness.unavailable_sections,truncated:n.completeness.truncated_sections,excluded:n.completeness.excluded})); } const comparison=await cli(['compare-web-captures',join(root,'valid.json'),join(root,'missing.json'),'--json']); if(comparison.code!==0)throw new Error(comparison.output+comparison.errors); console.log(JSON.stringify({comparison_exit_code:comparison.code,accessibility_comparison:JSON.parse(comparison.output).normalized_result.dimensions.accessibility})); }finally{ for(const socket of sockets.clients)socket.terminate(); await new Promise(r=>sockets.close(r));await new Promise(r=>server.close(r)); await rm(root,{recursive:true,force:true}); } JS ``` 3. Inspect the printed accessibility results and completeness fields for each variant, and the accessibility comparison result. The missing and malformed cases currently match the valid empty tree, and the valid-versus-missing comparison reports unchanged. ### Expected behavior A valid `nodes: []` reply can establish an empty accessibility inventory. A successful CDP reply that omits `nodes`, or reports `nodes: [null]`, cannot establish an empty tree. REA should reject those malformed observations or record their unavailable/incomplete accessibility coverage. A comparison involving that unavailable inventory must not report accessibility as unchanged. Usable nodes in a partly malformed reply should retain their own facts without establishing full coverage. `docs/browser-observation.md:267` and `docs/browser-observation.md:297` require omitted or invalid observations to carry completeness information. The passive comparison contract under "Shipped surfaces" at `docs/browser-observation.md:22` says missing or truncated evidence is never treated as equivalence. `docs/tool-design.md:55` requires missing coverage to remain unknown rather than absence. ### Impact and scope P2, supported. Malformed producer accessibility data causes a successful capture and a false unchanged result for a selected analysis dimension. The observed reach is bounded to the malformed reply cases; normal empty inventories remain valid. The failure affects the integrity of the reported accessibility coverage and comparison result. Workaround status is unverified. Recapturing may obtain a valid provider inventory, but no caller option exposes or validates the discarded reply. A practical way to distinguish a truly empty tree from this malformed capture was not established. ### REA version 6.4.0 (main at 8dde6370d0809ad04cda3342d4e6f290f48aa063) ### Runtime and platform Node.js v26.10.0, macOS 27.0.1 arm64. Dependencies and the compiled runtime were already present for the observed reproduction. ### Target and artifact context A simulated loopback HTTP/WebSocket CDP endpoint advertising FixtureChrome/1.0 and protocol version 1.3, one page target, and otherwise unchanged protocol replies. The inline program writes normalized page inspections in `{ "inspection": ... }` JSON files for the public comparison CLI. No installed Chrome release was exercised. ### Interface CLI ### Earliest failure stage Capture or observation ### Relevant output or evidence ```text All three inspections exited with code 0 and returned: {"total_nodes":0,"text_capture":{"status":"included","retained_bytes":0,"excluded_fields":0},"nodes":[]} Missing and malformed inventories: unavailable_sections=[] truncated_sections=[] No accessibility exclusion appeared. Valid empty inventory versus missing inventory, comparison exit code 0: {"total_changes":0,"changes":[],"status":"unchanged","reason":null} ``` The separate executed mixed reply retained one valid button and reported a total of one without an accessibility omission marker. The observed reproduction was complete for the simulated producer replies and public CLI path. ### Typed outcome or recovery expected Run the same valid, missing, and malformed reply cases. The valid empty tree should remain an empty complete observation. The missing and malformed inventories must be rejected or explicitly incomplete, and comparison against a valid empty tree must not claim accessibility equality. Include a partially malformed array containing a valid button to verify that surviving facts do not erase the omitted entry's coverage gap. ### Analysis or suspected cause Source-derived reasoning: `src/browser/CdpCaptureDocuments.ts:465` drops missing/non-record nodes before calculating the returned total, and `src/browser/CdpCaptureDocuments.ts:498` checks only surviving child references for incompleteness. `src/domain/webCaptureDiff.ts:689` consequently admits the empty projection as comparable. This reasoning is consistent with the executed command returning identical complete accessibility projections for different producer replies and an unchanged accessibility comparison. ### Additional context Related completed issue [#377](https://github.com/morluto/rea/issues/377) introduced accessibility and storage comparison and required missing/truncated evidence never to count as equality; it did not describe this malformed accessibility inventory trigger. Related completed issue [#2660](https://github.com/morluto/rea/issues/2660) concerns malformed cache-name inventories producing complete storage fingerprints; this failure concerns `Accessibility.getFullAXTree` and the accessibility dimension. Related completed issue [#2553](https://github.com/morluto/rea/issues/2553) concerns invalid aligned DOM arrays shifting facts between nodes; this failure concerns missing or discarded accessibility inventory entries becoming complete empty trees. Related completed issue [#2556](https://github.com/morluto/rea/issues/2556) concerns malformed Electron child-frame IDs being classified as scope exclusions; this failure concerns omitted accessibility coverage. Related open issue [#3172](https://github.com/morluto/rea/issues/3172) concerns unavailable redirect-hop response metadata being treated as known network evidence; this failure concerns malformed accessibility inventories. Related completed issue [#2169](https://github.com/morluto/rea/issues/2169) concerns mismatched scenario event-family selections producing false equality; this failure occurs during passive accessibility capture despite identical selected options. Related open issue [#3173](https://github.com/morluto/rea/issues/3173) concerns missing network-content recovery guidance alongside valid console differences; this failure concerns false accessibility equality after malformed producer replies. Related completed issue [#1830](https://github.com/morluto/rea/issues/1830) concerns unavailable final-response bytes being treated as a proven network change; this failure concerns missing accessibility enumeration being treated as a complete empty tree.
Author
Owner

@YunzhouLi-hub commented on GitHub (Oct 15, 2026):

我来承接这个。它和 #3379 是同一个产生边界,我会合成一个 PR 一起修(CONTRIBUTING.md 的 "Choosing a contribution" 要求 keep related corrections together)。

根因

src/browser/CdpCaptureValues.ts:15-21 的 recordsValue 是一个全函数,把三种在 CDP 协议上完全不同的生产者状态塌缩成了同一个 []:

CDP 实际回复 recordsValue 返回 语义
nodes: [] [] 生产者说确实是空的(合法)
nodes 缺失或不是数组 [] 生产者省略了字段(未知)
nodes: [null] [] 生产者返回了畸形条目(未知,且内容已丢失)

inspect-web-page --include-accessibility-text 在 src/browser/CdpCaptureDocuments.ts:465-467 读它:

const all = results.flatMap((result) =>
  recordsValue(requiredRecord(result).nodes),
);

三种情况都得到 total: 0、nodes: []、treeIncomplete: false。而 src/browser/CdpPageCapture.ts:410 的 if (result === undefined) unavailable = true 只在 CDP 命令本身失败时触发——命令成功返回但 nodes 畸形时它保持 false,于是 :413 的 completeness.unavailable("accessibility") 和 :419 的 not_approved 排除都不会执行。unavailable_sections 和 excluded 因此都是空的,比较时 accessibility 维度报 unchanged。

为什么这是产生边界而不是下游打补丁

仓库里已经有正确的惯用法,只是这条路径没走到:CdpCaptureCompleteness.unavailable(section) / exclude(section, reason),以及 src/domain/browserCompleteness.ts:104 已经正确地把 unavailableSections 映射成 attach_limited 状态。全仓库有 72 处调用这个惯用法,包括同族的 CdpPageCaptureWorkers.ts:33(if (result === undefined) completeness.unavailable("workers"))。只有 CDP 命令成功返回、而字段本身畸形的这几条路径在静默塌缩。

recordsValue 有 24 个调用点、跨 10 个文件(CdpElectronInspection、CdpCaptureStorage、CdpCaptureStorageFingerprints、CdpCaptureEventHandlers、CdpCaptureDocuments、CdpWebMcpDiscovery、CdpPageCaptureWorkers、CdpElectronWorkers、CdpCaptureEventHelpers),所以这是一个被广泛复用的语义漏洞,不是单点遗漏。

计划

结构性修法:让 recordsValue 把准入结果报出来,使调用方能区分「合法空数组」和「畸形/缺失」。具体是加一个 admitRecords(value),对缺失/非数组返回 undefined(未知)、对合法空数组返回 [],然后把确有记账需求的调用点路由到 completeness.unavailable(section)。我会逐个调用点确认 blast radius,其余保持现状。

验证

用 issue 里的 loopback HTTP/WebSocket CDP fixture(不需要真实 Chrome 或 Electron)覆盖四种模式:合法空树、nodes 缺失、nodes: [null]、以及一个 null 加一个合法 button 的混合回复。断言 unavailable_sections 非空、excluded 非空,并且与合法空树的比较不再报 accessibility unchanged。本地跑 npm run test:local、npm run check:fast、npm run verify:test-discovery,并在回复里报告实测结果和任何未覆盖的部分。

我基于 upstream/main 4135c911d 读的源码,上面的行号对应该 revision。开工前会先 rebase 到最新 main。

<!-- gh-comment-id:6184413187 --> @YunzhouLi-hub commented on GitHub (Oct 15, 2026): 我来承接这个。它和 #3379 是同一个产生边界,我会合成一个 PR 一起修(CONTRIBUTING.md 的 "Choosing a contribution" 要求 keep related corrections together)。 ## 根因 ``src/browser/CdpCaptureValues.ts:15-21`` 的 ``recordsValue`` 是一个全函数,把三种在 CDP 协议上完全不同的生产者状态塌缩成了同一个 ``[]``: | CDP 实际回复 | recordsValue 返回 | 语义 | | --- | --- | --- | | ``nodes: []`` | ``[]`` | 生产者说确实是空的(合法) | | ``nodes`` 缺失或不是数组 | ``[]`` | 生产者省略了字段(未知) | | ``nodes: [null]`` | ``[]`` | 生产者返回了畸形条目(未知,且内容已丢失) | ``inspect-web-page --include-accessibility-text`` 在 ``src/browser/CdpCaptureDocuments.ts:465-467`` 读它: ```ts const all = results.flatMap((result) => recordsValue(requiredRecord(result).nodes), ); ``` 三种情况都得到 ``total: 0``、``nodes: []``、``treeIncomplete: false``。而 ``src/browser/CdpPageCapture.ts:410`` 的 ``if (result === undefined) unavailable = true`` 只在 CDP 命令本身失败时触发——命令成功返回但 ``nodes`` 畸形时它保持 false,于是 ``:413`` 的 ``completeness.unavailable("accessibility")`` 和 ``:419`` 的 not_approved 排除都不会执行。``unavailable_sections`` 和 ``excluded`` 因此都是空的,比较时 accessibility 维度报 unchanged。 ## 为什么这是产生边界而不是下游打补丁 仓库里已经有正确的惯用法,只是这条路径没走到:``CdpCaptureCompleteness.unavailable(section)`` / ``exclude(section, reason)``,以及 ``src/domain/browserCompleteness.ts:104`` 已经正确地把 ``unavailableSections`` 映射成 ``attach_limited`` 状态。全仓库有 72 处调用这个惯用法,包括同族的 ``CdpPageCaptureWorkers.ts:33``(``if (result === undefined) completeness.unavailable("workers")``)。只有 CDP 命令成功返回、而字段本身畸形的这几条路径在静默塌缩。 ``recordsValue`` 有 24 个调用点、跨 10 个文件(CdpElectronInspection、CdpCaptureStorage、CdpCaptureStorageFingerprints、CdpCaptureEventHandlers、CdpCaptureDocuments、CdpWebMcpDiscovery、CdpPageCaptureWorkers、CdpElectronWorkers、CdpCaptureEventHelpers),所以这是一个被广泛复用的语义漏洞,不是单点遗漏。 ## 计划 结构性修法:让 ``recordsValue`` 把准入结果报出来,使调用方能区分「合法空数组」和「畸形/缺失」。具体是加一个 ``admitRecords(value)``,对缺失/非数组返回 ``undefined``(未知)、对合法空数组返回 ``[]``,然后把确有记账需求的调用点路由到 ``completeness.unavailable(section)``。我会逐个调用点确认 blast radius,其余保持现状。 ## 验证 用 issue 里的 loopback HTTP/WebSocket CDP fixture(不需要真实 Chrome 或 Electron)覆盖四种模式:合法空树、``nodes`` 缺失、``nodes: [null]``、以及一个 null 加一个合法 button 的混合回复。断言 ``unavailable_sections`` 非空、``excluded`` 非空,并且与合法空树的比较不再报 accessibility unchanged。本地跑 ``npm run test:local``、``npm run check:fast``、``npm run verify:test-discovery``,并在回复里报告实测结果和任何未覆盖的部分。 我基于 upstream/main ``4135c911d`` 读的源码,上面的行号对应该 revision。开工前会先 rebase 到最新 main。
Author
Owner

@YunzhouLi-hub commented on GitHub (Oct 15, 2026):

PR 已提交:https://github.com/morluto/rea/pull/3850

基于 upstream/main 788864d52 重新读源码并 rebase 后实施,源码结构与我上面读的 4135c911d 有变化,所以修正一下根因描述里的两个数字:recordsValue 实际是 19 个调用点、跨 9 个文件(我先前写成 24/10);同族惯用法的实例是 CdpCaptureDocuments.ts:105-114 的 web 侧 captureResources,它已经做了完全正确的事,我这条路径照着它做。

实际采用的修法

没有保留 recordsValue 作为别名,而是换成两个导出:

  • admitRecords(value) 对缺失/非数组返回 undefined(未知),只有 present array 携带生产者证据;
  • admittedRecords(value) 保留原来的逐条 record 过滤,给不需要记账的调用方用,文档字符串明说畸形条目被无声丢弃、需要记账的调用方应改用 admitRecords + 逐条 recordValue。

留着 recordsValue 就是留着这个陷阱默认值给下一个调用方(AGENTS.md 的 "Remove superseded representations, aliases, wrappers")。

路由到记账簿的 5 个调用点(另一个是 web/Electron 跨适配器复制的 Target.getTargets,同属 #3379):

调用点 section
CdpCaptureDocuments.ts captureAccessibility accessibility
CdpElectronInspection.ts captureResources resources
CdpElectronInspection.ts captureDom dom
CdpPageCaptureWorkers.ts captureWorkers workers
CdpElectronWorkers.ts captureElectronWorkers workers

captureDom 在字符串表缺失时直接停掉:没有字符串表时 documentURL 索引无法解析,继续跑会把每篇文档误归因为 out_of_target_scope(policy 原因,进 policy_filtered_sections 而不是 unavailable_sections),是错误归因。

另外,Electron 侧 captureResources 原来自己手写了一个 BFS while 循环(pending.shift()),没用仓库里已有的 walkFrameTrees(它本来就带畸形子帧记账回调)。这次顺手换成共享遍历,superseded representation 和缺失的记账一起消失。

剩余的、这个 PR 没做的

有 13 个调用点没有可达的记账簿,我没有改它们,只在 PR 正文里报告为 remaining cleanup:

  • CdpCaptureDocuments.ts accessibilityStates:掉一个畸形 state 会让 states 不完整,但 treeIncomplete 看不到。要报它需要新的 schema 字段,属于另一个 PR。
  • CdpCaptureStorageFingerprints.ts 8 处:该文件自己已有 Array.isArray 守卫 + cookies.length !== cookieValues.length 掉数检测。
  • CdpCaptureStorage.ts、CdpCaptureEventHelpers.ts、CdpCaptureEventHandlers.ts、CdpWebMcpDiscovery.ts。

实测

全部在 rebase 后的 base 788864d52 上跑:

命令 结果
tsc --noEmit clean
npm run check:fast 2/2 tasks successful
npm run test:local 4 个改动测试文件 47/47
npm run test:local src/browser 全部 34 个测试文件 452/452
npm run test:local -- tests/evaluation/moduleBoundaries.test.ts 113/113
npm run verify:test-discovery 1313 files, all once
npx oxfmt --check src/browser/ correct format
npm run docs:check 4/5;artifacts:managed-evidence 因 mkdtemp EPERM 失败,在无改动的干净树上同样失败,是环境问题

回归证明

把 admitRecords 原地改回旧的塌缩原语(缺失返回 [] 而不是 undefined),47 个测试里 12 个失败,每个受影响 section 各一到多个:

CdpCaptureValues.test.ts > keeps an absent field distinct from a present empty array CdpCaptureDocuments.test.ts > reports a frame that answered without its node inventory CdpCaptureDocuments.test.ts > reports a non-array node inventory rather than an empty tree CdpCaptureDocuments.test.ts > distinguishes a malformed frame from a legitimately empty sibling CdpElectronInspection.test.ts > reports a frame whose resource inventory is missing rather than empty CdpElectronInspection.test.ts > reports a non-array resource inventory rather than empty CdpElectronInspection.test.ts > keeps a child frame's authorized resources after its parent answered CdpElectronInspection.test.ts > reports a snapshot without its string table rather than empty names CdpElectronInspection.test.ts > reports a snapshot whose documents field is not an array CdpCaptureWorkers.test.ts > web: reports a missing target inventory as unavailable rather than no workers CdpCaptureWorkers.test.ts > web: reports a non-array target inventory as unavailable CdpCaptureWorkers.test.ts > Electron: reports a missing target inventory as unavailable rather than no workers

代表性断言差异(base revision):

`

  • "excluded": [{ "count": null, "reason": "invalid_protocol_value", "section": "resources" }],
  • "unavailable_sections": ["resources"],
  • "excluded": [],
  • "unavailable_sections": [],
    `

剩下 8 个新测试两种实现下都过,这是设计如此:它们走的是逐条 recordValue 路径,base revision 本来就处理了,是用来防止过度上报的对照组。

未覆盖的部分

那 13 个无记账簿可达的调用点保持现状,没有测试锁定它们的 ledger(没有 ledger 可锁定)。没有驱动真实 Chrome/Electron 会话——改动的边界是 CDP 应答解释器,loopback fixture 覆盖的是同一处分支,与仓库现有 browser 测试的边界相同。

<!-- gh-comment-id:6184829608 --> @YunzhouLi-hub commented on GitHub (Oct 15, 2026): PR 已提交:https://github.com/morluto/rea/pull/3850 基于 upstream/main `788864d52` 重新读源码并 rebase 后实施,源码结构与我上面读的 `4135c911d` 有变化,所以修正一下根因描述里的两个数字:`recordsValue` 实际是 **19 个调用点、跨 9 个文件**(我先前写成 24/10);同族惯用法的实例是 `CdpCaptureDocuments.ts:105-114` 的 web 侧 `captureResources`,它已经做了完全正确的事,我这条路径照着它做。 ## 实际采用的修法 没有保留 `recordsValue` 作为别名,而是换成两个导出: - `admitRecords(value)` 对缺失/非数组返回 `undefined`(未知),只有 present array 携带生产者证据; - `admittedRecords(value)` 保留原来的逐条 record 过滤,给不需要记账的调用方用,文档字符串明说畸形条目被无声丢弃、需要记账的调用方应改用 `admitRecords` + 逐条 `recordValue`。 留着 `recordsValue` 就是留着这个陷阱默认值给下一个调用方(AGENTS.md 的 "Remove superseded representations, aliases, wrappers")。 路由到记账簿的 5 个调用点(另一个是 web/Electron 跨适配器复制的 `Target.getTargets`,同属 #3379): | 调用点 | section | | --- | --- | | `CdpCaptureDocuments.ts` `captureAccessibility` | `accessibility` | | `CdpElectronInspection.ts` `captureResources` | `resources` | | `CdpElectronInspection.ts` `captureDom` | `dom` | | `CdpPageCaptureWorkers.ts` `captureWorkers` | `workers` | | `CdpElectronWorkers.ts` `captureElectronWorkers` | `workers` | `captureDom` 在字符串表缺失时直接停掉:没有字符串表时 `documentURL` 索引无法解析,继续跑会把每篇文档误归因为 `out_of_target_scope`(policy 原因,进 `policy_filtered_sections` 而不是 `unavailable_sections`),是错误归因。 另外,Electron 侧 `captureResources` 原来自己手写了一个 BFS while 循环(`pending.shift()`),没用仓库里已有的 `walkFrameTrees`(它本来就带畸形子帧记账回调)。这次顺手换成共享遍历,superseded representation 和缺失的记账一起消失。 ## 剩余的、这个 PR 没做的 有 13 个调用点没有可达的记账簿,我**没有**改它们,只在 PR 正文里报告为 remaining cleanup: - `CdpCaptureDocuments.ts` `accessibilityStates`:掉一个畸形 state 会让 `states` 不完整,但 `treeIncomplete` 看不到。要报它需要新的 schema 字段,属于另一个 PR。 - `CdpCaptureStorageFingerprints.ts` 8 处:该文件自己已有 `Array.isArray` 守卫 + `cookies.length !== cookieValues.length` 掉数检测。 - `CdpCaptureStorage.ts`、`CdpCaptureEventHelpers.ts`、`CdpCaptureEventHandlers.ts`、`CdpWebMcpDiscovery.ts`。 ## 实测 全部在 rebase 后的 base `788864d52` 上跑: | 命令 | 结果 | | --- | --- | | `tsc --noEmit` | clean | | `npm run check:fast` | 2/2 tasks successful | | `npm run test:local` 4 个改动测试文件 | 47/47 | | `npm run test:local` `src/browser` 全部 34 个测试文件 | 452/452 | | `npm run test:local -- tests/evaluation/moduleBoundaries.test.ts` | 113/113 | | `npm run verify:test-discovery` | 1313 files, all once | | `npx oxfmt --check src/browser/` | correct format | | `npm run docs:check` | 4/5;`artifacts:managed-evidence` 因 `mkdtemp` EPERM 失败,在无改动的干净树上同样失败,是环境问题 | ## 回归证明 把 `admitRecords` 原地改回旧的塌缩原语(缺失返回 `[]` 而不是 `undefined`),47 个测试里 **12 个失败**,每个受影响 section 各一到多个: ` CdpCaptureValues.test.ts > keeps an absent field distinct from a present empty array CdpCaptureDocuments.test.ts > reports a frame that answered without its node inventory CdpCaptureDocuments.test.ts > reports a non-array node inventory rather than an empty tree CdpCaptureDocuments.test.ts > distinguishes a malformed frame from a legitimately empty sibling CdpElectronInspection.test.ts > reports a frame whose resource inventory is missing rather than empty CdpElectronInspection.test.ts > reports a non-array resource inventory rather than empty CdpElectronInspection.test.ts > keeps a child frame's authorized resources after its parent answered CdpElectronInspection.test.ts > reports a snapshot without its string table rather than empty names CdpElectronInspection.test.ts > reports a snapshot whose documents field is not an array CdpCaptureWorkers.test.ts > web: reports a missing target inventory as unavailable rather than no workers CdpCaptureWorkers.test.ts > web: reports a non-array target inventory as unavailable CdpCaptureWorkers.test.ts > Electron: reports a missing target inventory as unavailable rather than no workers ` 代表性断言差异(base revision): ` - "excluded": [{ "count": null, "reason": "invalid_protocol_value", "section": "resources" }], - "unavailable_sections": ["resources"], + "excluded": [], + "unavailable_sections": [], ` 剩下 8 个新测试两种实现下都过,这是设计如此:它们走的是逐条 `recordValue` 路径,base revision 本来就处理了,是用来防止过度上报的对照组。 ## 未覆盖的部分 那 13 个无记账簿可达的调用点保持现状,没有测试锁定它们的 ledger(没有 ledger 可锁定)。没有驱动真实 Chrome/Electron 会话——改动的边界是 CDP 应答解释器,loopback fixture 覆盖的是同一处分支,与仓库现有 browser 测试的边界相同。
Author
Owner

@YunzhouLi-hub commented on GitHub (Oct 16, 2026):

This is fixed on main.

Commit 4cc044b (fix(browser): retain malformed inventory uncertainty) separates a missing CDP accessibility inventory from an observed empty one:

  • captureAccessibility now admits Accessibility.getFullAXTree results through admitRecords, so an omitted or non-array nodes field records exclude("accessibility", "invalid_protocol_value", null) instead of collapsing into an empty array.
  • CdpPageCapture.ts forwards completeness: events.completeness into it, so the section reaches unavailable_sections/excluded with an attach_limited condition rather than reporting an unchanged empty tree.
  • Each node is admitted individually: a non-record entry, a missing or empty nodeId, a non-boolean ignored, or malformed parentId/childIds increments the malformed count that lands in excluded.

The commit message carries Refs #3376, but Refs does not close an issue, so this stays open until it is closed manually or referenced with Fixes.

Related: #3379, which shared the same producing boundary, was closed with #3901.

<!-- gh-comment-id:6205940733 --> @YunzhouLi-hub commented on GitHub (Oct 16, 2026): This is fixed on main. Commit `4cc044b` (`fix(browser): retain malformed inventory uncertainty`) separates a missing CDP accessibility inventory from an observed empty one: - `captureAccessibility` now admits `Accessibility.getFullAXTree` results through `admitRecords`, so an omitted or non-array `nodes` field records `exclude("accessibility", "invalid_protocol_value", null)` instead of collapsing into an empty array. - `CdpPageCapture.ts` forwards `completeness: events.completeness` into it, so the section reaches `unavailable_sections`/`excluded` with an `attach_limited` condition rather than reporting an unchanged empty tree. - Each node is admitted individually: a non-record entry, a missing or empty `nodeId`, a non-boolean `ignored`, or malformed `parentId`/`childIds` increments the malformed count that lands in `excluded`. The commit message carries `Refs #3376`, but `Refs` does not close an issue, so this stays open until it is closed manually or referenced with `Fixes`. Related: #3379, which shared the same producing boundary, was closed with #3901.
Author
Owner

@morluto commented on GitHub (Oct 16, 2026):

Fixed on main in 4cc044be6. Browser inventories distinguish missing or malformed responses from observed empty arrays and preserve valid sibling nodes with explicit incomplete coverage.

Controlled CDP/provider regressions cover malformed/missing accessibility and related inventories; no real Chrome/Electron execution is claimed.

Hosted main CI is still pending; this closure is based on the implemented fix and the scoped regression evidence.

<!-- gh-comment-id:6206049587 --> @morluto commented on GitHub (Oct 16, 2026): Fixed on `main` in [4cc044be6](https://github.com/morluto/rea/commit/4cc044be68f813e96907e6cedcf422d0a3170de3). Browser inventories distinguish missing or malformed responses from observed empty arrays and preserve valid sibling nodes with explicit incomplete coverage. Controlled CDP/provider regressions cover malformed/missing accessibility and related inventories; no real Chrome/Electron execution is claimed. Hosted main CI is still pending; this closure is based on the implemented fix and the scoped regression evidence.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
morluto/rea#1244
No description provided.