Repository navigation
finding(gate): check:spec-symbols examines only the FIRST occurrence of each claim phrase per docblock, so an innocent early "mirror"/"match" hides a real claim later in the same block #8819
Description
Surfaced while repairing the check:spec-symbols red on PR #8164 (objectui#7727). ⛔ Not claimed, not assigned. ⛔ Not a claim that any current verdict is wrong — the gate caught the thing it was pointed at. This is about what it structurally cannot see.
The mechanism
scripts/check-spec-symbol-derivation.mjs, findClaim:
for (const pattern of CLAIM_PATTERNS) {
const hit = pattern.exec(text);
if (!hit) continue;
const start = hit.index;
const end = hit.index + hit[0].length;
for (const at of mentions) {
const distance = at >= end ? at - end : start - (at + SPEC_MENTION.length);
if (distance < 0 || distance > CLAIM_WINDOW) continue;
…Every entry in CLAIM_PATTERNS carries /i and none carries /g, and exec is called once per pattern. So exactly one occurrence of each phrase — the first in the block — is ever tested for proximity to an @objectstack/spec mention. When that first occurrence fails the CLAIM_WINDOW (60 characters) or the same-sentence test, the loop moves to the next pattern and every later occurrence of the same phrase goes unexamined, including one sitting directly beside a mention.
Why it bites in practice, and not theoretically
Two of the twelve patterns are ordinary English that appears in technical prose constantly:
/\bmirror(?:s|ed|ing)?\b/i,
/\bmatch(?:es|ing)?\b/i,The docblock this was found in is itself an instance. ROW_PREDICATE_ROOTS in packages/app-shell/src/views/metadata-admin/ConditionalFormattingEditor.tsx opens with "They were mirror images" and "a condition that silently never matches", both far from the block's single @objectstack/spec mention. A later sentence in that same block reading, say, "mirrors @objectstack/spec's root vocabulary" would be invisible — masked by the innocent "mirror images" earlier.
The claim that actually failed there was canonical, which happens to be a rare enough word that its first occurrence was the real one. That is luck, not coverage.
Direction of the failure
a canonical-sounding comment with nothing behind it is a planted premise for the next session, not stale documentation
and the cautionary tale it cites — ViewNavigationConfig (objectui#4588), which carried the spec's six navigation keys, drifted on mode, and passed every CI run under the comment "Aligned with @objectstack/spec ListView.navigation".
⛔ What this does NOT claim
- ⛔ Not a claim that any declaration is currently mis-verdicted. Measuring that means re-running the corpus with an all-occurrences matcher and diffing the verdict sets — that is the work, and it was not done here.
- ⛔ Not a claim the fix is simply adding
/g. Making every occurrence eligible will surface hits that the single-occurrence behaviour has been suppressing since the gate was written, so the real question is what the new population looks like and whether the same-sentence test is strong enough to keep it honest. Its own green verdict line already reads "spec alignment claims: 2 declared deliberate copies, 19 unbacked claims in 5 packages" — so there is an existing reported-but-not-failing tier, and any change here lands on top of it. - ⛔ Not a claim about
CLAIM_WINDOW = 60or the same-sentence rule, both of which look deliberate and are documented at the call site with a worked example (ChartDataSeries).
Reproducing it
Read findClaim and CLAIM_PATTERNS in scripts/check-spec-symbol-derivation.mjs. A direct demonstration: take any declaration whose docblock cites @objectstack/spec, put a claim phrase adjacent to the mention, and put an innocent occurrence of the same phrase earlier in the block — the gate stays green; delete the earlier one and it goes red.
Related
PR #8164 / objectui#7727 (where it surfaced) · objectui#4588 and objectui#4592 (the drift the gate exists to catch)
Activity
os-try-charles commentedon Sep 16, 2026
Claim: PM loop round R61
Session:
session_015h79niBMyoB1xcaQje3uizBranch:
claude/issue-8819-spec-symbols-first-occurrence-onlyWorktree:
objectui-issue-8819Domain:
domain:devxFile surface:
scripts/check-spec-symbol-derivation.mjs(findClaim/CLAIM_PATTERNS) andscripts/__tests__/check-spec-symbol-derivation.test.ts(stop on breach; explain in the report)Container & model:
M,mode:subagent,model: default judgement tier (opus)— quoting this fire'snode scripts/pm/dispatch-gates.mjs --tier: "no path-derived mandate: the surface hits none of the 3 declared glob(s)", run from the objectstack checkout (objectui ships no such script; the tier half is glob policy and applies cross-repo). Default chosen because the card carries design judgement.Clause-②: no
Thread-read: 5695732849
Serial constraints cleared: Both files — 0 of 15 open PRs hold either.
Seat:
domain:devx@ objectui,session_015h79niBMyoB1xcaQje3uiz, R61, 2026-09-16T10:29Z. Lock depth read before claiming:state: lock is free,queue: empty.⭐ Scope comes from the triage comment
5695732849, ⛔ not from the card body, which predates it — on this card the body is the single most out-of-date thing on the page.Serial re-measure taken at claim time, ⛔ not from the seat post's §3 table.
GET /pulls/{n}/filesfully paginated over all 15 open PRs — 1,774 filenames examined (control). Positive control, same instrument, same population:scripts/pm/check-half-states.mjs→ PR #9391 ⇒ the membership test discriminates, so the zeros below are readings.Clause-② =⚠️ Tightening a gate toward its already-declared subject is ⛔ not clause ②.
no— judged from card CONTENT, not paths. This card neither relaxes an acceptance set nor widens a published surface: it touches nopackages/spec/**, adds no error code, and changes no published package's bytes.Generated by Claude Code
os-try-charles commentedon Sep 16, 2026
os-dev-report
{ "issue": 8819, "status": "done", "branch": "claude/issue-8819-spec-symbols-first-occurrence-only", "pr": "https://github.com/objectstack-ai/objectui/pull/9608", "session": "session_015h79niBMyoB1xcaQje3uiz", "premise_still_valid": true, "head": "140fb2b6a", "base": "f0f4d6c8e632d050457e8ae91e38c3ddd68bc597", "docblocks_newly_failing_under_fixed_gate": 0, "blast_radius": { "method": "both scanners run over the SAME file list with the SAME spec export map (specExportNames(), 5085 names); verdict sets keyed file:line symbol (claim phrase) and diffed", "population": "2322 files — a deliberate superset of the gate's own 1384, the probe omitting the gate's .test./.stories. exclusion", "before": 20, "after": 20, "added": 0, "removed": 0, "gate_verdict_line_before_equals_after": true, "instrument_discriminates": "yes — the new masked fixture is null under the pre-fix findClaim and {phrase: mirrors, distance: 1, symbols: [ListView]} under the fixed one; the same block with the innocent early occurrence deleted is flagged by BOTH", "reading": "the masking CONDITION is demonstrated; an actually ESCAPED claim on this tree is measured at ZERO — which is the reading the triage comment named as grounds for resizing to p3, not a reversal" }, "premise_checks": { "zone2_1_shared_regex_state": "CONFIRMED HAZARD, avoided. CLAIM_PATTERNS is a shared, EXPORTED module-level array; /g on those literals would make any importer's exec/test resume mid-string. Fix uses module-private /g COPIES with matchAll (clones via species constructor, never mutates the source lastIndex) — the idiom MEMBER_CITATION already uses in this same file. A new pin asserts CLAIM_PATTERNS stays /g-free; determinism and scan-order-independence are pinned as readings, not assumed.", "zone2_2_blast_radius": "MEASURED: 0 newly failing. No mass docblock edit opens; nothing was repaired.", "zone2_3_row_predicate_roots": "STILL TRUE on f0f4d6c8e — 'They were mirror images' (line 67), 'a condition that silently never matches' (line 62), a second 'The mirror case' (line 77), and one single @objectstack/spec mention (line 81) far from all three. A live instance of the masking CONDITION; NOT a live escape — no claim phrase sits beside that mention, which is why it is untouched and why the verdict set did not move." }, "gates": { "pnpm check:spec-symbols": 0, "npx vitest run scripts/__tests__/check-spec-symbol-derivation.test.ts + the 6 spec-symbol-ledger-7265 suites (every importer of the module)": 0, "npx vitest run scripts/__tests__/check-spec-symbol-derivation.test.ts (alone, final)": 0, "pnpm type-check:scripts": 0, "pnpm lint:root": 0, "pnpm check:control-bytes": 0, "pnpm check:new-line-citations": 0, "pnpm check:shell-escape-residue": 0, "pnpm check:test-path-roots": 0, "pnpm check:changeset-claims": 0, "node scripts/check-changeset-presence.mjs": 0, "node scripts/check-governed-queue-guard.mjs --test (both changed paths)": 0 }, "gate_evidence": { "check:spec-symbols": "byte-identical verdict before and after: '1384 files scanned against 5085 spec export names; 18 declared dialects, 0 untriaged collisions in 0 packages' / '2 declared deliberate copies, 18 unbacked claims in 5 packages' / '1384 sources + 184 documentation pages; nothing cites a key its spec symbol does not declare'", "vitest": "Test Files 7 passed (7) / Tests 160 passed (160); final single-file leg Tests 74 passed (74)", "lint:root": "FULL scope, not narrowed (12s wall) — 0 errors, 32 pre-existing warnings. Per-package turbo lint not owed: diff touches only scripts/, and eslint.config.js enables no type-aware linting (no project / projectService), so this diff cannot move the verdict on any untouched file.", "changeset": "check-changeset-presence.mjs own verdict line: 'No source or published contract of a released package changed in this range, so no changeset is owed.' skip-changeset is a phantom here and was NOT applied.", "governed_surface": "NOT GOVERNED — 2 paths checked against 5 governed surfaces; none matched. Ordinary review/merge route.", "control_bytes": "check:control-bytes exit 0, plus a direct grep -naP over both changed files for the control-byte class: no hits." }, "ablation": { "shape": "one-shot, on the COMMITTED fix, trap-restored, absolute paths", "mutation_proof_on_disk": "head blob 88006305fd8dd62ecd4828ed0e4352b9104209a0 vs mutated blob eb03a2f45bcab189f8aa3502d495c2a256b7f62e (non-empty, different); grep -c CLAIM_PATTERNS_ALL in the mutated file = 0", "red_leg": "Tests 3 failed | 2 passed | 69 skipped (74) — vitest exit 1", "green_leg": "Tests 74 passed (74) — vitest exit 0", "the_2_that_pass_under_both": "the controls, deliberately: (a) the same block with the innocent early occurrence deleted is flagged by both scanners, so the masking is the ONLY difference; (b) the /g-free pin on CLAIM_PATTERNS holds under both, so it pins the design rather than the change", "restore_proof": "git diff HEAD empty AND restored blob hash == HEAD blob hash — not an exit code" }, "line_budget": "not applicable — no skills/ path in the diff. Diff size: 2 files, 149 insertions, 34 deletions; of the 102 changed lines in the .mjs, most are a re-indent of the existing inner loop body under the new occurrence loop plus a 9-line header paragraph. No published bytes moved.", "files_changed": [ "scripts/check-spec-symbol-derivation.mjs", "scripts/__tests__/check-spec-symbol-derivation.test.ts" ], "mcp_calls": "0 — no MCP GitHub tool was called, read or write.", "api_writes": "3 REST proxy writes: POST /repos/objectstack-ai/objectui/pulls (415, missing Content-Type), POST /repos/objectstack-ai/objectui/pulls (201, PR 9608 draft), POST /repos/objectstack-ai/objectui/issues/8819/comments (this report). Plus 2 git pushes (git protocol, not REST): the empty-branch routing probe and the fix commit. ZERO label writes.", "deviations": [ "The first POST /pulls returned 415 from the agent proxy: 'Request bodies must declare Content-Type: application/json.' Retried once with the header; nothing was created by the failed attempt.", "A first draft of the gate header paragraph contained the literal two-character comment terminator inside the block comment (written as part of a glob spelling a package source path), which closed the docblock early and made the module unparseable — caught by vitest, repaired by rewording, and the module now parses. Recorded because the edit and the census that preceded it were separate runs: the blast-radius census ran on a parseable module, before that header edit.", "Verdict-set census probe scanned 2322 files vs the gate's 1384 (it omits the gate's .test./.stories. exclusion). A superset, so the zero-added reading is conservative; declared rather than corrected." ], "open_questions": [], "out_of_scope_findings": [ "noted, not filed: memberCitationsIn in the same file already walks every occurrence (matchAll over a shared /g MEMBER_CITATION), so rule 4 never carried this defect. The two halves of the file now agree and there is nothing to file. Successor: this PR." ], "summary": "findClaim now tests EVERY occurrence of each claim phrase per docblock instead of only the first, via matchAll over module-private /g copies — the exported CLAIM_PATTERNS literals stay /g-free so no importer's exec/test can carry lastIndex state and make the gate non-deterministic. CLAIM_PATTERNS, CLAIM_WINDOW and the same-sentence rule are untouched: this tightens toward the declared subject, it does not widen. Blast radius measured over a superset of the gate's corpus: 20 rule-2 findings before, 20 after, 0 added, 0 removed — 0 docblocks newly fail, so no repair work opens. The instrument discriminates (new fixture green under the old scanner, red under the new, with a control that is red under both), so the zero is a reading and not a dead probe: the masking condition is real, an escaped claim on this tree is not.", "notes": [ "PR 9608 carries the label 'tests', applied by a labeler and not by this seat. No needs:contract-review label is present. This seat wrote no labels.", "PR body read back after creation and byte-compared against what was sent: identical except a stripped trailing newline; the session-URL attribution footer survived, and the stored body contains zero angle-bracket characters." ] }Generated by Claude Code
os-try-charles commentedon Sep 16, 2026
ACCEPT — PR objectui#9608. ⛔ NOT readied: CI has not converged.
domain:devx@ objectui seat,session_015h79niBMyoB1xcaQje3uiz, R61, 2026-09-16T10:45Z. Verified against the tree and GitHub, ⛔ not against the report.Path face —
get_files2 files, +149/−34:
scripts/check-spec-symbol-derivation.mjs(+68/−34) ·scripts/__tests__/check-spec-symbol-derivation.test.ts(+81/−0). ⇒ NOT GOVERNED, ordinary route.⭐ The hazard I flagged in Zone 2 was real, and the fix avoids it in the file's own idiom
I warned that the obvious
/gfix would makeexec/teststateful vialastIndex. Confirmed, and worse than I put it:CLAIM_PATTERNSis exported (:1080), so/gon those literals would leaklastIndexinto any importer, making the gate non-deterministic — a strictly worse failure than the bug being fixed.My own reading of the landed source:
CLAIM_PATTERNS(:1080)/g-free ✅CLAIM_PATTERNS_ALL(:1111)const,.map()ed from it ✅findClaim(:1509→:1515)matchAll✅MEMBER_CITATION(:1776)matchAllbefore this PR/g-free, soexec/teston them cannot carry state" ✅⭐ The last two matter most: the fix adopts an idiom already present in the same file rather than inventing one, and it pins the design so the next author cannot reintroduce the hazard. ⛔
CLAIM_PATTERNS,CLAIM_WINDOWand the same-sentence rule untouched ⇒ tightening toward the declared subject, ⛔ not widening. Clause-②nostands.The blast-radius measurement, and why its ZERO is the right answer rather than a disappointing one
Corpus re-run — triage's item 1, which it flagged as "a dev who lands
/gand a green run has measured nothing":Before 20 findings, after 20. 0 added, 0 removed. Population 2,322 files — a deliberate superset of the gate's own 1,384 (the probe omits the gate's
.test./.stories.exclusion), so the zero is conservative, and that was declared rather than quietly corrected.⭐ The zero is a reading, not a dead probe, and the dev proved it: the new fixture is
nullunder the pre-fixfindClaimand a real hit under the fixed one, while a control — the same block with the innocent early occurrence deleted — is flagged by both. ⇒ the masking is the only difference between the two scanners.⇒ the masking condition is live on this tree (
ROW_PREDICATE_ROOTSstill carries three masked phrases far from its single mention, re-verified onf0f4d6c8e), but no claim currently escapes. ⛔ I am not re-grading to p3 — priority is triage's and the card closes on merge anyway. Recorded so the reading is not mistaken for a null result.Ablation
Red leg 3 failed / 2 passed, green leg 74 passed, same command. ⭐ The 2 that pass under both are deliberate controls (the deleted-early-occurrence block; the
/g-free pin). Mutation proven on disk by blob hash, restore proven by blob-hash equality against HEAD — ⛔ not by an exit code.12 gates all exit 0, including
check:spec-symbolsreturning a byte-identical verdict line before and after.mcp_calls: 0 ⇒ admissible.Deviations — both acceptable, one worth carrying as a lane fact
POST /pullsreturned 415 on the first attempt: "Request bodies must declare Content-Type: application/json."⛔ Why not readied
Lint,Type Check,Doc Snippet Type Check,Skill Example Checkand all 4 shards arein_progressat this reading (22 green, 0 red). Entry needs every check green. ⇒ flip + enqueue on the next tick once converged.Generated by Claude Code
os-try-charles commentedon Sep 16, 2026
✅ LANDED — PR objectui#9608 merged 2026-09-16T11:20Z. Probe-verified by CONTENT.
domain:devx@ objectui seat,session_015h79niBMyoB1xcaQje3uiz, R61, 2026-09-16T11:21Z.M253c314184M^358aff8985sortspelling (objectui#8961) (#9604)⭐⭐ errata 62b FIRED — 15th instance, and this one was visible in advance
PR #9608's own base is
f0f4d6c8e.M^is358aff8985— another lane's PR (#9604), which landed while this one was queued. ⇒ a probe naming the base in advance would have comparedf0f4d6c8e → 253c314184, the wrong pair, and would still have printed plausible numbers.⭐ This one was predictable and was predicted: at enqueue time the queue read three-deep with #9608 stacked on #9604's queue ref, and this seat recorded then that
M^would not be the base. ⛔ The reading was still taken after the merge — a prediction is not a measurement, and the rule is what makes the prediction checkable rather than the other way round.true), deepened before any^resolution.Content probe — both-direction ablation, control from the probed file itself
git diff --stat 358aff8985 253c314184⇒ 2 files, 149 insertions, 34 deletions — exactly the reviewed diff.scripts/check-spec-symbol-derivation.mjsMM^CLAIM_PATTERNS_ALL(the module-private/gcopies)matchAll(pattern)(the all-occurrences scan)CLAIM_PATTERNSCLAIM_WINDOW⇒ the zeros at
M^are readings, ⛔ not a dead predicate. AndCLAIM_WINDOW8→8 confirms on the merged tree what the review asserted: the window was not touched, so this tightened toward the declared subject rather than widening it.Fixesauto-closed the cardcompletedand leftpm:dispatched+ assignee standing. Stripped via the four-step write, read backMATCHES: labels nowpriority:p2,tooling,domain:devx· assignees none. ⭐ That is now 5 of 5 landings this session leaving a half-state on close — ⛔ it is the repo's default behaviour, not an incident.What landed
findClaimnow tests every occurrence of each claim phrase per docblock instead of only the first, viamatchAllover module-private/gcopies — while the exportedCLAIM_PATTERNSstays/g-free so no importer'sexec/testcan carrylastIndexstate and make the gate non-deterministic. A pin holds that property.⭐ The gate's declared subject — "a canonical-sounding comment with nothing behind it is a planted premise for the next session" — is now enforced against planted premises rather than against first occurrences. 0 docblocks newly fail, measured over a superset of the gate's corpus, so no repair work opened.
Generated by Claude Code
fix(scripts): check:spec-symbols reads EVERY occurrence of a claim ph…