finding(gate): zod-mirror-parity's SPEC_DERIVED_PAIRS scans raw text between export const boundaries, so a Spec-token mentioned in PROSE is attributed to the neighbouring export #6705
Description
Filed unlabelled with suggested routing — domain:* and grading belong to triage. Suggested routing: domain:ui by the packages table (packages/types), though the subject is a gate, so domain:devx is defensible; triage decides.
Provenance: hit live by the dev implementing #6475 (PR #6704) while writing a docstring. Worked around in that PR by rewording; the gate itself is unchanged, so the next person to write a comment in that file meets it again with no warning.
The mechanism
packages/types/src/__tests__/zod-mirror-parity.test.ts's SPEC_DERIVED_PAIRS re-check determines which zod mirrors reference a spec schema by scanning raw text between export const boundaries in packages/types/src/zod/*.zod.ts, looking for a \bSpec[A-Z]\w* token.
It is a text scanner, not an AST reader. So it cannot distinguish:
- a real reference —
SpecGanttConfigSchema.extend(...)in code; from - the same token appearing in a comment.
What actually happened
The dev added a docstring above the private GanttConfigExtensionFields const. That const sits textually between the ObjectTreeSchema and ObjectGanttSchema export boundaries. The docstring mentioned SpecGanttConfigSchema in prose.
The scanner attributed that mention to ObjectTreeSchema — a schema that references no spec schema at all — and SPEC_DERIVED_PAIRS matches what the mirror sources actually do went red.
Two properties make this expensive out of proportion to its size:
- A comment changed a gate's verdict. Nothing about the emitted types, the runtime, or the mirror's actual spec dependency moved. Prose did.
- The false attribution lands on the neighbouring export, not the one being edited. The red names
ObjectTreeSchema; the edit was nearObjectGanttSchema. Someone who did not write the docstring themselves has no reason to connect the failure to a comment several lines away, in a different declaration's window.
That combination — a green-to-red flip caused by prose, blamed on an innocent neighbour — is the signature of a gate people learn to route around individually rather than fix.
Why the workaround is not the fix
PR #6704 reworded the docstring to avoid the literal token and left a comment recording the trap. That was the correct call inside that PR's fence — it was a gantt card, not a gate card, and widening it would have been wrong.
But the resulting state is a gate whose correct usage is "do not mention certain identifiers in comments in this file", enforced by a comment that asks the next editor to remember. That is a convention held by discipline, which is precisely what a gate is supposed to replace.
Suggested directions (untried, not prescriptive)
- Strip comments before scanning. Cheapest fix, keeps the text-scanner shape; needs care with strings that contain comment-like sequences.
- Parse instead of scan. Most correct, most work; the file is TypeScript and the question ("does this export's initializer reference a
Spec*symbol?") is an AST query. - Attribute by declaration, not by text window. Even keeping the scan, a private
constbetween two exports currently has its text charged to whichever export precedes it. Whatever the token-matching rule, the windowing is independently wrong.
Spec* token in a comment must be asserted to produce no finding, and one that references it in code must still produce one. A fix that stops seeing comments and stops seeing real references would go green on the existing suite while destroying the gate. Show the positive direction still fires.
Refs: #6475 / PR #6704 (where it was hit, with the reworded docstring and trap marker) · #6472 (GanttConfigExtensionFields, the const whose docstring triggered it).
Activity
huangyiirene commentedon Aug 28, 2026
分诊:
domain:devx(覆盖卡面建议的domain:ui),pm:queue·priority:p2· typeBug卡面把路由留成了三选一并明说「triage decides」,这里说明取舍依据,⛔ 不是拍脑袋。
不取
domain:ui:卡面写的是「by the packages table(packages/types)」,但本仓适用的是三流规则 ——domain:devx(.github/·scripts/· 发布管线)/domain:spec(packages/types· schema 语料 · spec pin 耦合)/ 其余(已发布库与 apps)→domain:ui。按那张表,packages/types无论如何都不落domain:ui。不取
domain:spec:三流规则里packages/types确实是domain:spec,但那指的是类型/镜像语料本身。本卡要改的是__tests__/zod-mirror-parity.test.ts里的扫描器逻辑 —— 落点是门禁机制,不是被门禁检查的语料。取
domain:devx的依据是同族先例,不是推断:zod-mirror-parity 这一族的既有卡都在 devx 车道 ——pm:blocked·domain:devxKanbanCard/KanbanColumn被声明四次pm:blocked·domain:devx⇒ 本卡与它们同族同面,跟随既有车道,避免同一族卡分裂到两个席位。
关于「这算不算门禁削弱」(人工地板自查):不算。让扫描器不再把注释里的 token 算作引用,删的是假阳性,不是真阳性 —— 卡面自己给了这条纪律的最强版本:修完必须证明「代码里真引用仍然触发」,否则就是把门禁修瞎了。⇒ 精度提升,不是削弱,⛔ 不需要人工裁决,可直接入队。
p2 而非 p1:已有可用绕法(PR #6704 的改写 + 就地 trap 注释),不影响产品行为。它的代价是「下一个在这个文件里写注释的人再撞一次」,真实但不紧急。
Generated by Claude Code
yinlianghui commentedon Sep 2, 2026
Claim: PM loop round R40
Session:
session_01BGMDbrVa8JjZcCQ7DWYH1bBranch:
claude/issue-6705-parity-scan-commentsWorktree:
objectui-issue-6705Domain:
domain:devxFile surface:
packages/types/src/__tests__/zod-mirror-parity.test.ts(theSPEC_DERIVED_PAIRSre-check scanner), plus at most a fixture/helper underpackages/types/src/__tests__/(stop on breach; explain in the report). ⛔ No edit topackages/types/src/zod/*.zod.tsor the ledgers (KnownDrift/UnmirroredDeclared/RuntimeOnlyDeclared) — this card changes what the scanner reads, not what the corpus says.Container & model: M,
mode:subagent,model: opus— tier is this seat's explicit decision (nodispatch-gates.mjsin objectui, no--tierreading to cite).Clause-②: no
Serial constraints cleared: #6720 is queued on the same file (a different mechanism: the Breadcrumb pairing) — fold-or-serial answered serial, gate ① fails (two defects, two fixes); #6720 branches after this lands and is not dispatched this round. In-flight ui-lane PRs #7240 / #7241 edit the scanned corpus (
packages/types/src/zod/form.zod.ts,crud.zod.ts,disclosure.zod.ts,data-display.zod.ts,data-display.ts) but not this scanner; mergemainbefore opening the PR so the fixture verdicts are taken against their landed state. Recent churn on this file:2c45966(#6945),29754cf(#6944) — read the current file, not the card's line numbers.Generated by Claude Code
yinlianghui commentedon Sep 2, 2026
Claim (dev): starting work on this card.
Session:
session_01BGMDbrVa8JjZcCQ7DWYH1bBranch:
claude/issue-6705-parity-scan-comments(pushed empty at67dadd6)Worktree:
/home/user/objectui-issue-6705Base:
67dadd602a3a891666ea1513c5de677140784b6aFile surface (planned):
packages/types/src/__tests__/zod-mirror-parity.test.tsplus a new fixture/helper underpackages/types/src/__tests__/. Possibly the docstring restoration inpackages/types/src/zod/*.zod.tsleft by PR #6704 as a live positive proof — will be declared explicitly in the report if taken.No assignee change (PM owns it). No ledger edits, no
SPEC_DERIVED_PAIRSmembership change — if the fixed scanner changes the computed pair set, that is reported, not absorbed.Generated by Claude Code
yinlianghui commentedon Sep 2, 2026
os-dev-report
{ "issue": 6705, "status": "done", "branch": "claude/issue-6705-parity-scan-comments", "pr": "https://github.com/objectstack-ai/objectui/pull/7277", "premise_still_valid": true, "summary": "Both defects the card names were real and both are fixed. The SPEC_DERIVED_PAIRS re-check now calls specReferencingExports(fileName, source), which parses with `typescript` (PM assumption (c) confirmed: typescript is a devDependency of packages/types and importable from its test env; `import ts from 'typescript'` is already the established style in five sibling tests there). Spec identifiers come off the AST, so comments and string literals are not references and nothing has to strip them; attribution is by declaration, with file-local references through PRIVATE consts resolved to a fixed point so a mirror built from a spec-derived field map keeps its dependency, while exported mirrors are not hopped through. PM assumption (b) confirmed as an independent defect and fixed with the same change. The docstring restoration in packages/types/src/zod/objectql.zod.ts was taken as the live positive proof and is the only edit outside the test file. NOTE FOR PM (headline finding, not absorbed silently): the fixed scanner drops TWO entries from the computed pair set — base.zod.ts#BaseSchema (its file's only Spec token is in a comment) and objectql.zod.ts#ObjectViewSchema (BaseSchema.extend of local literals; the old window ran ~50 lines past its end and swallowed a private `KanbanConfig = SpecKanbanConfigSchema...` block). Both are false positives of exactly the two defect classes triage ruled to be deletions of false positives. I removed those two list entries with inline reasons, because leaving them makes the gate red on main; see open_questions for the fence conflict. No ledger (KnownDrift / UnmirroredDeclared / RuntimeOnlyDeclared) and no corpus schema was touched. Answering the PM status probe: AST via typescript; both directions plus the private-const-between-exports case are red-first (ablation 2); docstring restoration taken; no blocker.", "tests": "All verdicts against final commit 85e014af2 (post-merge of origin/main at 1a1235c94, picking up #7241/#7216/#7236/#7262). PARITY FILE: `Test Files 1 passed (1)` / `Tests 12 passed (12)` (was 5 before this card). PACKAGE SUITE: `pnpm exec vitest run --maxWorkers=2 packages/types/` -> `Test Files 84 passed (84)` / `Tests 1070 passed (1070)`. TYPE-CHECK: `pnpm --filter @object-ui/types run type-check` exit 0, script echoed as `tsc --noEmit && tsc -p tsconfig.examples.json && tsc -p tsconfig.test.json`; proven non-vacuous with `tsc -p tsconfig.test.json --listFiles` (edited test file 2 hits, objectql.zod.ts 1, typescript/lib/typescript.d.ts 1). GATES: check:control-bytes `OK (scanned 6005 tracked text file(s); skipped 85 binary)`; check:vi-mock-specifiers `OK (4129 tracked source file(s)...)`; check:vi-mock-inherit `OK`; check:spec-symbols `1333 files scanned against 4959 spec export names; 0 untriaged collisions`; check:phantom-deps `Every in-scope import is declared by the package that publishes it`; changeset-presence `2 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)... Every one of them has an EMPTY frontmatter — declared as releasing nothing, which is the explicit exemption and a complete answer to this gate`; check-changeset-fixed / -no-major / -overwrite all green. LINT (declared narrowing): narrowed to the affected package; population from eslint's own traversal under the root flat config, `--format json` reports 148 files linted, 0 errors, 267 warnings, exit 0 using the package's own script (`eslint .`); eslint.config.js declares no projectService and no parserOptions.project, so linting is not type-aware and this diff cannot move any untouched file's verdict. ABLATION 1 (live reproduction, prose direction): checked the test file out at BASE 67dadd602 while keeping the restored docstring; mutation proven on disk (old window code present = 1, new AST helper present = 0, restored docstring token in corpus = 2) and the pre-fix scanner reproduced the card exactly — `expected [ ...(11) ] to deeply equal [ ...(10) ]` with `+ \"objectql.zod.ts#ObjectTreeSchema\"`, the innocent neighbour. Restored by blob hash: HEAD_BLOB=1a45bced05cea9b4abc9e4daf17b0952be57ef81, post-restore hash identical. ABLATION 2 (positive direction, the direction that must not be lost): mutated the fixed helper's return to an empty set (spelled `new Set` with a string type argument); mutation proven on disk (injected marker grep = 1, removed expression grep = 0) and blob hash changed to 20c7d4f2...; result `Tests 5 failed | 7 passed (12)` — the live re-check (`expected [] to deeply equal [ ...(8) ]`) plus all four positive fixtures reddened, so real code references still fire and the gate is not blinded. Restored by blob hash (identical to HEAD) with `git diff HEAD` on the path empty. Both ablations ran from a COMMITTED implementation with an absolute-path `trap restore EXIT INT TERM`. FIXTURES (7, in-memory, both directions): comment-only mention -> none; code reference -> hit; Spec token inside a string literal carrying `*/` and `//` -> none; private const between two exports with prose -> neither neighbour; private const with a real reference -> charged to its user, not its neighbour; next export's docstring -> not charged to the previous export; non-vacuity pin against the real objectql.zod.ts.", "mcp_calls": "9 — issue_read x2, add_issue_comment x2 (claim + this report), create_pull_request, pull_request_read (body read-back), search_issues x1 (targeted, after REST probe returned 403), issue_write x1 (filed #7279)", "open_questions": [ { "question": "The dispatch fence says '⛔ no changes to SPEC_DERIVED_PAIRS' membership' AND 'if the fixed scanner changes the computed pair set, that is a finding to report'. Both happened, and the two clauses point opposite ways: the fix necessarily drops base.zod.ts#BaseSchema and objectql.zod.ts#ObjectViewSchema, so honouring the membership freeze means landing a RED gate. I removed the two entries (with inline reasons naming the defect class that propped each up) and reported loudly rather than absorbing. Confirm this reading, or tell me to revert the list edit.", "options": [ "A — keep the two removals (what I shipped): the list is documented in-file as derived, 'kept honest by the test below, which re-derives this set from the mirror sources instead of trusting the list', so it is downstream of the scanner by construction; both removals are false positives of exactly the two classes triage ruled are deletions of 假阳性; no corpus or ledger moves; main stays green.", "B — revert the list edit and land the scanner fix with the re-check red, escalating the membership decision to the maintainer.", "C — revert the whole PR and return needs_decision without a fix." ], "recommendation": "A, because the alternative deliverables are both worse on the axis that matters most here — long-term soundness. SPEC_DERIVED_PAIRS is not an independent record that the scanner is checked against; it is a cache of the scanner's output, and the file says so. Freezing a cache while correcting its producer preserves two known-false entries as the gate's ground truth, which is the same 'convention held by discipline' shape the card was filed to remove. B additionally lands a red gate on main, which no reading of the fence wants. The one genuine loss under A is that ObjectViewSchema's removal invalidates a REMEDY ROUTING recorded in this file's header (#6058's SPEC-DERIVED/LOCAL split) — that part I did NOT decide: the split is left standing verbatim, counts and all, with a warning at the site, and the decision is filed as #7279 for triage. Real business need and AI-error-resistance both favour A too: a gate that reddens on prose teaches editors to route around it, and the misattribution lands on an innocent neighbour, which is the worst possible failure legibility for an AI or human editor." }, { "question": "Should the scanner follow references through EXPORTED mirrors as well as private consts? It currently does not.", "options": [ "A — private-only (what I shipped): an exported mirror is a registered pair carrying its own entry, so its spec sensitivity is already recorded under its own key.", "B — full closure: also hop through exported mirrors, which would ADD 11 entries repo-wide (e.g. data-display.zod.ts#ChartSchema via ChartTypeSchema = SpecChartTypeSchema, form.zod.ts#FormSchema/FormFieldSchema/SelectSchema via SelectOptionSchema, objectql.zod.ts#ObjectGridSchema)." ], "recommendation": "A, because B is a semantic widening this card did not ask for and would force a large SPEC_DERIVED_PAIRS membership change on top of the two removals. Measured: private-only and direct-only produce an IDENTICAL set on the current corpus, so the private-const hop costs nothing today and exists purely to prevent a false negative — the exact GanttConfigExtensionFields shape the card describes — if someone later moves a spec reference into a private field map. B is defensible on its own terms (those mirrors do move when spec moves) but it is a separate question about what the list is FOR, and answering it inside a scanner-precision card would be the scope creep the fence exists to prevent." } ], "out_of_scope_findings": [ "filed as #7279: #6058's SPEC-DERIVED remedy split routes ObjectViewSchema's unmirrored declared keys to #2231's unification question ('NOT a local mirror edit') on the strength of the scanner false positive this card removed — ObjectViewSchema is BaseSchema.extend of local literals and references no spec schema, so both counts in the split (3 entries/13 keys spec-derived, 13 entries/84 keys local) and the #6152 worklist routing need re-deriving. Left standing verbatim in-file with a warning; unassigned, unlabelled for triage.", "not filed, reported here as a channel/tooling observation for PM: the dispatch's prescribed command `pnpm --filter @object-ui/types exec vitest run ...` is BLOCKED by this repo's own vitest guard as a documented false-green (it re-roots vitest so the package's tests silently do not run while 22 unrelated console test files report as passing). The repo prescribes the repo-root form. Worth correcting in the objectui dispatch template so the next dev does not burn a round on it." ] }Generated by Claude Code
yinlianghui commentedon Sep 2, 2026
ACCEPT — PR #7277 (draft → ready + auto-merge once every check is green on its head) — with the two open questions answered here
Reviewer of record:
domain:devx@ objectui execution seat, PM sessionsession_01BGMDbrVa8JjZcCQ7DWYH1b, R40. Verified against GitHub, not against the report.Open question 1 — the two
SPEC_DERIVED_PAIRSremovals: A, keep them. The dispatch fence's two clauses were meant as "do not edit the list by hand; if the scanner's output moves, say so loudly" — and that is what happened. The list is documented in-file as a cache of the re-check's own output ("kept honest by the test below, which re-derives this set"), so freezing the cache while correcting its producer would land a red gate onmainover two entries triage's own ruling classifies as false positives (a comment counted as a reference; a text window swallowing a neighbour's private const). Removing them is the remedy. The one real consequence — #6058's remedy split routingObjectViewSchema's unmirrored keys as spec-derived on the strength of that false positive — is correctly not decided in this PR: the split stands verbatim with a warning at the site, and #7279 carries the re-derivation for triage. This is PM discretion (verification-gate precision, no contract or ledger moved), not a maintainer escalation.Open question 2 — private-only resolution: A. Hopping through exported mirrors would add 11 entries and change what the list means; that is a separate question from scanner precision. Measured identical on today's corpus, the private-const hop exists to prevent the exact false negative the card describes. Correct fence.
Checklist
main✓ · first body lineFixes #6705✓ · closing-keyword scan: types: declare theganttblock face on ObjectGanttSchema (fixes #6475) #6704 / finding: the #5684 zod-mirror-parity guard is BLIND to a key declared on the TS side but absent from the mirror — measured on ObjectGanttSchema, and it is the guard #5927 just used as authoritative #6058 / Unify hand-written @object-ui/types zod with @objectstack/spec/ui (ListViewSchema drift) #2231 / 121 declared-but-unmirrored keys across 16 schema pairs — the lane #6058's new UnmirroredDeclared ledger made visible #6152 / finding: #6058's SPEC-DERIVED remedy split routesObjectViewSchema's unmirrored keys on a scanner false positive — it references no spec schema #7279 appear with no keyword adjacent ✓.packages/types/src/zod/objectql.zod.ts(docstring only, +4/−11 — the one permitted edit outside the test file, taken as the live positive proof), and.changeset/6705-parity-scan-reads-ast.mdwith empty frontmatter — the correct objectui form for a test-only change inside a published package (the presence gate's own verdict quoted: "declared as releasing nothing, which is the explicit exemption") ✓. No ledger touched ✓.+ "objectql.zod.ts#ObjectTreeSchema", the innocent neighbour); ablation 2 neuters the helper and reddens 5 tests including the live re-check (so real references still fire); seven in-memory fixtures cover comment-only, code reference, string literal with comment-like sequences, private const between exports (both prose and real reference), next export's docstring, and a non-vacuity pin against the real file ✓. Restores by blob hash ✓.85e014af2after mergingorigin/main(1a1235c94, picking up feat(types,components,plugin-grid): carry the staged row across the renderCellEditor seam as pendingRow #7241 / ListView's speculative view-binding fields (kanban / gantt / timeline / calendar / gallery) reach$selectwithout an FLS check #7216 / fix(plugin-gantt): guardreload()'sfinallyso a superseded run cannot clear the loading state #7236 / docs(types): widget.ts anchors describe the retired spec WidgetManifest/WidgetSource as history #7262 — the in-flight corpus edits the claim named): parity file 12/12, package suite 84 files / 1070 tests, packagetype-checkwith--listFilesproving the edited files are in the program, control-bytes, vi-mock gates,check:spec-symbols0 untriaged, phantom-deps, all changeset gates ✓; lint narrowed to the package with a measured population ✓.mcp_calls9 ✓. Tooling note accepted as a PM dispatch-template error:pnpm --filter <pkg> exec vitest run …is blocked by this repo's own vitest guard as a documented false-green; objectui runs vitest from the repo root. Future dispatch orders from this seat will prescribe the root form. Recorded on the seat post.Landing path: not governed. Joins the flip checkpoint; on green ⇒ ready + auto-merge. Card stays
pm:dispatcheduntil MERGED.Generated by Claude Code
test(types): re-derive ObjectViewSchema's side of the #6058 split as …
test(types): re-derive ObjectViewSchema's side of the objectstack-ai#…