Skip to content

Document and harden the Difficulty Ladder peer contract (#13) - #17

Merged
cchacs merged 5 commits into
mainfrom
pullfrog/13-harden-difficulty-ladder-peer-contract
Oct 5, 2026
Merged

cchacs merged 5 commits into
mainfrom
pullfrog/13-harden-difficulty-ladder-peer-contract

Conversation

@pullfrog

@pullfrog pullfrog Bot commented Oct 5, 2026

Copy link
Copy Markdown

Implements #13. The per-section difficulty glass is Section Map's only cross-plugin dependency, and neither the README nor the manifest documented it. This pins the contract in screen.js/CLAUDE.md/README, hardens the handshake, and declares the optional peer in plugin.json.

What changed

Runtime hardening (screen.js)

  • _smEnsureDifficultySubscription() replaces the one-shot capability check and now runs from every 200 ms _smUpdate() tick as well as on mount. See the rationale below — this is the fix with the widest blast radius.
  • _smIsDynamicDifficultyAvailable() requires window._ddCapabilities.sectionDifficulty === true rather than the mere existence of that global, so a foreign user of _ddCapabilities can't unlock a subscription whose payload we can't render.
  • New _smNormalizeSectionDifficulties(): drops entries without a finite numeric fillPercentage and returns null ("ignore this event") distinctly from {} ("clear the glasses"). Two things this fixes — the peer's arithmetic yields NaN when mastery or phrase difficulty isn't a number, and NaN is typeof 'number', so it reached the style attribute as height:NaN% with a Difficulty: NaN% title; and a payload container that isn't an object no longer wipes good state.
  • glassSize is allowlisted against a new SM_GLASS_SIZES table via _smGlassSize() before either render path interpolates it — it's the only payload field that reaches markup unescaped, as data-size.
  • _smRenderGlassFilling() / _smUpdateDifficultyFills() now use Number.isFinite as defence in depth rather than relying solely on the normalizer.
  • The subscription flag now means "a handler is registered", not "we have an unsubscribe handle": once feedBack.on() has been called we mark it subscribed even if the bus returned nothing, so a bus without a disposer can't leak one handler per tick.

Docs

  • README gains a "Difficulty glasses (optional)" section: what the peer contributes, the event and schema consumed, the feature-detection marker, and the compatibility notes.
  • CLAUDE.md gains the peer-contract rules, including two that exist specifically to stop a future refactor from reintroducing the one-shot check.

Manifest

  • version → 1.3.0 (the version is the cache-buster for served JS/CSS, so this is required for the fix to reach users).
  • A capabilities.difficulty-ladder declaration: roles: ["observer"], observes: ["difficulty:sections-updated"], compatibility: "degrade-noop", safety: "safe". The plugin schema permits additional properties and defines no dependency key, so this is the surface the spec provides for cross-plugin participation. Validated against the published plugin.schema.json.

Tests — node tests/screen.test.js: 35 pass, up from 23. New coverage: absent provider, a foreign _ddCapabilities global, a provider that appears after mount, a v0.12.0-shaped payload and a current one (a provider advertising sectionsSchema v3), events with no/unusable detail, unreadable payload entries, glassSize markup containment, show/hide re-subscription, retry-until-bus-available, and the no-unsubscribe-handle path.

Notes for the reviewer

  • The floor is v0.12.0, not the v0.9.13 in the issue. The event shipped in v0.2.0; the peer's CHANGELOG documents the difficulty_ladder.sections.v2 payload shape at 0.12.0. v0.9.13 appears never to have been released, and the peer's own README/CHANGELOG call v0.12.0 the floor. It is documented as a floor and deliberately not enforced: the capability marker carries no version and predates v0.2.0, so a below-floor peer passes the check and still renders — with the pre-v0.9.11 formula, which can disagree with the peer's own HUD. That's stated in the README rather than papered over.
  • Why the retry, precisely. static/js/plugin-loader.js sorts by name || id (ties by id) and injects screen.js one at a time awaiting each onload, so with both plugins ready in one response difficulty_ladder does sort first and a one-shot check happened to work. That is not a guarantee: the loader skips plugins whose status isn't ready, refetches on plugin-registered startup events, and the backend clears its registry at the start of load_plugins() and repopulates it incrementally — so responses can be partial. A mid-install peer then arrives on a later refetch, after our script body has run; our script doesn't re-run either (the loader's loadedScripts guard), so the tick is the only retry. An earlier commit in this branch cited session.js's "plugins load ASYNCHRONOUSLY" note for this, which is about the wrapper chain rather than competing plugin scripts; the second commit corrects it.
  • v3 is not consumed. difficulty:sections-updated-v3 is a separate, render-neutral payload emitted alongside v2; the peer's transition policy exists so an un-upgraded Section Map keeps working on the frozen v2 event. Replace section glasses with the shared difficulty tier rail #14 owns that rework, so nothing here sniffs schema to "upgrade".
  • Not changed: I did not add a standards key to the capabilities block. plugin-runtime-idempotent.v1 looks defensible (the idempotency guard is real and CI-checked), but I can't verify the full declaration contract from the spec alone, and it isn't what this issue asks for. Worth a separate issue if someone wants to make that claim.
  • A related claim in the peer's repo about Section Map's load-order dependency is now stale for the same reason as the bullet above; it would need the same correction there, which is out of scope for this PR.

🤖 Generated with Pullfrog

Pullfrog  | View workflow run | via Pullfrog | Using big-pickle (free) | 𝕏

pullfrog Bot added 2 commits October 5, 2026 12:51
The difficulty glasses are Section Map's only cross-plugin dependency, and
neither the README nor the manifest said so. Pin the contract in screen.js,
CLAUDE.md and the README, and harden the handshake itself.

- Retry the capability check on every 200ms poller tick instead of deciding
  once per player-screen mount. The old one-shot check relied on the Host
  loading plugins alphabetically; core's session.js says plugins load
  ASYNCHRONOUSLY "in whatever order the race settles", so a late or reloaded
  Difficulty Ladder left the feature unsubscribed for the rest of the session.
  There is no readiness event to subscribe to: the peer sets its marker at
  top-level script execution and declares no capability domain.
- Require window._ddCapabilities.sectionDifficulty === true rather than the
  mere existence of that global.
- Normalize payloads before rendering: drop entries whose fillPercentage is not
  a finite number (the peer's arithmetic can produce NaN, which sailed through
  the old typeof === 'number' check and reached the style attribute as
  height:NaN%), and ignore a payload container that isn't an object so the last
  good state survives. A well-formed map with nothing renderable still clears
  the glasses.
- Allowlist glassSize against SM_GLASS_SIZES: it is the one payload field that
  reaches markup unescaped, as data-size.
- Publish Difficulty Ladder v0.12.0 as the payload floor. The issue proposed
  v0.9.13, but that version was never released; the peer's own README and
  CHANGELOG call v0.12.0 the floor (its 0.12.0 entry is where
  difficulty_ladder.sections.v2 is documented; the event itself shipped in
  v0.2.0). Document it as a floor rather than a check: the capability marker
  carries no version and predates v0.2.0, so a below-floor peer still renders,
  it just computes fillPercentage with the pre-0.9.11 formula and can disagree
  with Difficulty Ladder's own HUD.
- Declare the optional peer in plugin.json's capabilities block (roles,
  observes, compatibility degrade-noop), which is the manifest surface the spec
  defines for cross-plugin participation: there is no dependency key.
- Add 12 peer-contract tests covering an absent provider, a foreign
  _ddCapabilities global, a late-loading provider, v0.12.0-shaped and current
  (v3-advertising) payloads, malformed events, unreadable entries, glassSize
  markup, show/hide re-subscription, and the no-unsubscribe-handle retry path.

v3 (difficulty:sections-updated-v3) is deliberately left unconsumed; #14 owns
that rework.
The retry itself was already right, but the rationale written for it was not:
it claimed core does not order plugin loads, citing session.js's note that
"plugins load ASYNCHRONOUSLY" (which is about the plugin/navigation wrapper
chain, not competing plugin scripts).

static/js/plugin-loader.js is more specific, and the real answer is stronger
than "ordering isn't guaranteed":

- it sorts plugins by `name || id`, ties broken by id, and injects screen.js
  one at a time awaiting each onload, so with both plugins 'ready' in a single
  response difficulty_ladder really does sort ahead of section_map;
- but it also skips any plugin whose status isn't 'ready', and the backend
  clears its registry at the start of load_plugins() and repopulates it
  incrementally, so a response can be partial;
- and it refetches on `plugin-registered` startup events.

So a peer mid-install lands on a later refetch, after our script body ran, and
our script does not run again either (loadedScripts guard). The tick remains the
only retry - only the explanation of why it is needed changes.
@pullfrog

pullfrog Bot commented Oct 5, 2026 •

Copy link
Copy Markdown
Author

The failing check was CodeFactor (the only check run in suite 101065616196) with a single annotation: unicorn/no-useless-spread on tests/screen.test.js:602. No CI job failed — node tests/screen.test.js was already green at 35/35 locally, as were the compliance checks.

The spread was a defensive copy of the handler list in the test double's fire(), so a handler unsubscribing mid-dispatch wouldn't mutate the array being iterated. Rather than suppress the rule or keep the throwaway array, I made the fake bus copy-on-write: on() stores a new list via concat, the returned disposer replaces it via filter. fire() now iterates the live list, which is safe because nothing mutates it in place.

Commit 58cb226 pushed to the PR branch. Verified: 35/35 tests pass, idempotent-top-level-guard still satisfied, working tree clean.

Pullfrog  | View workflow run | via Pullfrog | Using big-pickle (free) | 𝕏

@codacy-production

Copy link
Copy Markdown

Not up to standards ⛔

🔴 Issues 3 high

Alerts:
⚠ 3 issues (≤ 0 issues of at least minor severity)

Results:
3 new issues

Category Results
Security 3 high

View in Codacy

🟢 Metrics 14 complexity · 19 duplication

Metric Results
Complexity 14
Duplication 19

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

Comment thread screen.js
Comment thread screen.js
@pullfrog

pullfrog Bot commented Oct 5, 2026 •

Copy link
Copy Markdown
Author

Addressed both threads from the kilo-code-bot review on #17.

Handler leak on hide/show (fixed, pushed as fbe601c) — _smStopRealtimeHooks() reset _smDifficultySubscribed = false unconditionally, so on a bus that offers neither an unsubscribe handle nor off the handler could never be detached yet the flag claimed otherwise, letting each player-screen cycle register another copy. The reset now happens only inside the disposer branch, which leaves a disposer-less bus at exactly one registration. 35/35 tests pass.

Capability-gate strictness (verified, no code change) — I fetched the peer's screen.js from get-flashbacks/feedback-plugin-difficulty-ladder@main, which does set window._ddCapabilities.sectionDifficulty = true (plus playerContext and sectionsSchema). Its own inline comment names Section Map's probe as gating on exactly that marker, so the strict check matches the contract rather than inventing one, and I did not loosen it to the bare-global back-compat the comment suggested. Reading the same file also confirmed the payload we normalize is the peer's real shape ({ fillPercentage, glassSize, avgDifficulty, maxDifficulty }, with glassSize confined to small/medium/large), and that the peer uses the same typeof unsubscribe === 'function' → fb.off → no-op fallback chain, which is what made the first fix necessary to get right.

Both threads resolved; a fresh re-read of the review surfaced no additional comments.

Task list (8/8 completed)
  • Create task list (done)
  • Checkout PR Document and harden the Difficulty Ladder peer contract (#13) #17 branch
  • Fetch review comments for review 5414976373
  • Read and understand review feedback
  • Address each comment appropriately (reply/change code)
  • Test changes if code was modified
  • Commit, push, reply to threads, resolve if appropriate
  • Report final progress

Pullfrog  | View workflow run | via Pullfrog | Using big-pickle (free) | 𝕏

@kilo-code-bot

kilo-code-bot Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Code Review Summary

Status: 1 Issue Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 1
SUGGESTION 0
Issue Details (click to expand)

WARNING

File Line Issue
screen.js 107 Capability gate requires exactly _ddCapabilities.sectionDifficulty === true; if the real peer advertises a different marker the glasses are silently disabled for all users, and every in-repo test sets the marker itself, so nothing here validates the peer's actual shape. Re-verified still present at current HEAD (3c961a8).
Files Reviewed (1 file)
  • tests/screen.test.js - 0 issues

Notes

  • Incremental scope since fbe601c is one commit touching tests/screen.test.js only: the fakeEventBus() comment was reworded to say fire() captures the handler list when it starts, so a mid-dispatch subscribe/unsubscribe can't disturb the dispatch in progress. Re-read fire() at tests/screen.test.js:601 — for (const handler of handlers.get(eventName) || []) does capture the array once at loop entry, and on()/the disposer both set a fresh array, so the new wording is accurate. This resolves the prior finding on tests/screen.test.js:595; the code itself was untouched.
  • No new inline comments posted this pass — the one unresolved finding is already covered by an active comment on screen.js:107.
  • The screen.js:198 accumulation suggestion remains fixed (_smStopRealtimeHooks() still clears both flags inside the typeof _smDifficultyUnsubscribe === 'function' branch, screen.js:230-233).
  • The screen.js:107 warning is unchanged code, but the thread carries an external claim that the peer's screen.js sets sectionDifficulty alongside playerContext/sectionsSchema. That claim is not verifiable from this repository, so the finding is carried forward rather than dropped.
  • No test execution in this session (read-only), so the reported 35/35 pass count remains unverified.

Fix these issues in Kilo Cloud

Previous Review Summaries (3 snapshots, latest commit fbe601c)

Current summary above is authoritative. Previous snapshots are kept for context only.

Previous review (commit fbe601c)

Status: 1 Issue Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 1
SUGGESTION 0
Issue Details (click to expand)

WARNING

File Line Issue
screen.js 107 Capability gate tightened from "global exists" to the exact shape { sectionDifficulty: true }; if the real peer advertises any other marker the glasses are silently disabled for all users, and every test sets the marker itself, so nothing in-repo validates the peer's actual shape. Re-verified still present at current HEAD (fbe601c).
Files Reviewed (1 file)
  • screen.js - 1 issue

Notes

  • Incremental scope since 58cb226 is one commit in screen.js only: _smStopRealtimeHooks() now clears _smDifficultyUnsubscribe / _smDifficultySubscribed inside the typeof _smDifficultyUnsubscribe === 'function' branch. That correctly resolves the previous SUGGESTION at screen.js:198 — a disposer-less bus now keeps exactly one registration across hide/show cycles instead of one per cycle. No new finding from the change: _smSubscribeFeedBackEvent() (screen.js:53) normalizes a non-function on() return into an off()-backed closure or null, so the branch condition is a reliable proxy for "detachable"; the residual handler persisting while the player is hidden matches the pre-existing one-directional teardown policy and _smBar is cleared by _smRemove(), so no DOM work is done off-screen.
  • No new inline comments posted this pass; the one unresolved finding is already covered by an active comment on screen.js:107.
  • No test execution in this session (read-only), so the reported 35/35 pass count is unverified.
  • Still unconfirmed from this repo: the peer marker shape behind the screen.js:107 warning (difficulty_ladder lives in another repository).

Fix these issues in Kilo Cloud

Previous review (commit 58cb226)

Status: 2 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 1
SUGGESTION 1
Issue Details (click to expand)

WARNING

File Line Issue
screen.js 107 Capability gate tightened from "global exists" to the exact shape { sectionDifficulty: true }; if the real peer advertises any other marker the glasses are silently disabled for all users, and every test sets the marker itself, so nothing in-repo validates the peer's actual shape. Re-verified still present at current HEAD.

SUGGESTION

File Line Issue
screen.js 198 _smDifficultySubscribed now means "registered", but _smStopRealtimeHooks() resets it (screen.js:234) even when no disposer exists, so a bus with neither an unsubscribe handle nor off accumulates one handler per player hide/show cycle. Re-verified still present at current HEAD.
Files Reviewed (1 file)
  • tests/screen.test.js - 0 issues

Notes

  • Incremental scope since 81a91bf is one commit touching only the test double: fakeEventBus.on() now replaces the handler list (concat/filter) instead of mutating it, and fire() dropped its spread copy. That pair is self-consistent — for...of holds the array reference captured at loop start, so a handler that subscribes or unsubscribes during dispatch cannot extend or truncate the current dispatch, and self-removal cannot loop. No production code is affected.
  • The one inaccurate line is a code comment: it states "fire() iterates the live list, and a handler is free to (un)subscribe while it runs", but because the list is now replaced rather than mutated, the loop iterates the snapshot taken when fire() was entered. Behaviour is correct; only the rationale is stale. Not filed as a finding.
  • No test execution in this session (read-only, no code run), so the suite's pass count is unverified.
  • Still unconfirmed from this repo: the peer marker shape behind the screen.js:107 warning (difficulty_ladder lives in another repository).

Fix these issues in Kilo Cloud

Previous review (commit 81a91bf)

Status: 2 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 1
SUGGESTION 1
Issue Details (click to expand)

WARNING

File Line Issue
screen.js 107 Capability gate tightened from "global exists" to the exact shape { sectionDifficulty: true }; if the real peer advertises any other marker the glasses are silently disabled for all users, and every new test sets the marker itself, so nothing in-repo validates the peer's actual shape.

SUGGESTION

File Line Issue
screen.js 198 _smDifficultySubscribed now means "registered", but _smStopRealtimeHooks() resets it even when no disposer exists, so a bus with neither an unsubscribe handle nor off accumulates one handler per player hide/show cycle for the rest of the session.
Files Reviewed (5 files)
  • screen.js - 2 issues
  • tests/screen.test.js - 0 issues
  • plugin.json - 0 issues
  • CLAUDE.md - 0 issues
  • README.md - 0 issues

Notes

  • The payload hardening is sound: _smNormalizeSectionDifficulties keeps null ("ignore event") distinct from {} ("clear glasses"), Number.isFinite replaces the typeof === 'number' checks that let NaN reach height:NaN%, and glassSize is now allowlisted through _smGlassSize() before it reaches data-size — that closes the one unescaped interpolation in the glass markup.
  • The retry wiring is correct: _smEnsureDifficultySubscription() is idempotent per tick, stays reachable before the bus is up, marks subscribed once a handler exists (so a disposer-less bus cannot leak per tick), and the song:ready/bar-reset paths still clear cached difficulty state.
  • Not verified in this session (read-only, no test execution): the new test suite was not run, and the external peer contract could not be inspected — difficulty_ladder lives in another repository. The peer-marker assumption behind the screen.js:107 warning is therefore unconfirmed either way; it is the one item worth resolving before merge.
  • CI: a bot comment reports a CI failure on this PR, and Codacy reports 3 high-severity security alerts on the changed code. The glass markup is the likely sink it flags (data-size="${glassSize}" at screen.js:507), which this PR's allowlist now constrains; the failing check itself was not re-run here.

Fix these issues in Kilo Cloud


Reviewed by free · Input: 46.3K · Output: 2.8K · Cached: 171.3K

pullfrog Bot added 2 commits October 5, 2026 13:13
CodeFactor flagged unicorn/no-useless-spread on fire(): it copied the
handler list before iterating. Make the fake bus copy-on-write in on()
instead, so fire() can iterate the live list directly -- same dispatch
semantics, no throwaway array.
@cchacs

cchacs commented Oct 5, 2026

Copy link
Copy Markdown

@pullfrog merge if all Codacy findings are false positives

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ Codacy's three action_required annotations were triaged individually and all three are false positives, so this is mergeable as-is. One stale comment inline.

The merge itself could not be executed from this run — the token lacks the scope. Details below.

Reviewed changes — the Codacy "Object Injection" trio on screen.js (action_required, 3 new issues), triaged against the full diff, plus the whole screen.js peer-contract diff and its 12 new tests. Suite re-run locally: 35/35 pass.

  • screen.js:128 — const entry = raw[key] (Variable Assigned to Object Injection Sink). key is bound by for (const key of Object.keys(raw)), so the key space is exactly the payload's own enumerable keys — there is no attacker-chosen index. The value read is type-checked on the next line (!entry || typeof entry !== 'object') and value-checked on the one after (Number.isFinite(entry.fillPercentage)), so nothing unvalidated survives the loop body.
  • screen.js:131 — normalized[key] = entry (Generic Object Injection Sink). The write target is a {} created two lines earlier in the same call and never shared or reused as a prototype. I confirmed the __proto__ edge case empirically: a payload key of __proto__ only re-points that local object's own prototype — Object.prototype stays clean (Object.prototype.fillPercentage === undefined), no global is polluted, and nothing reads _smSectionDifficulty's prototype chain. _smGetSectionDifficulty looks up a numeric loop index only (screen.js:140), and every consumer re-checks the entry before rendering.
  • screen.js:503 — const glassStyle = SM_GLASS_SIZES[glassSize] (Variable Assigned to Object Injection Sink). Provably bounded: glassSize comes from _smGlassSize() one line above, which returns the peer string only when Object.prototype.hasOwnProperty.call(SM_GLASS_SIZES, size) confirms it, else the literal 'medium'. I verified the allowlist rejects __proto__, constructor and toString, so the index is one of small|medium|large and the value is one of three constant CSS strings. This is also the inverse of a real finding — the line it replaces was sizeStyles[difficulty.glassSize] || sizeStyles.medium, an unguarded peer-string index into an object literal that would have resolved sizeStyles["constructor"] to Object. Hoisting the table to module scope and allowlisting through _smGlassSize() is the fix; the rule can't see the guard.

Worth stating once, since "Security: 3 high" reads worse than it is: the payload originates from difficulty_ladder, a plugin the user installed into the same page and origin, running with identical privileges. There is no trust boundary between the two plugins, so peer-supplied keys are not attacker-supplied. The surface that was worth closing — glassSize reaching data-size unescaped — is closed by the allowlist this PR adds, and Number.isFinite stops the peer's NaN arithmetic from painting height:NaN%.

The new tests are real coverage rather than fixtures that pass either way: the glassSize test asserts markup containment against "><script>alert(1)</script>, the unreadable-entry test asserts Object.keys(...) is [] for a payload of five unreadable entries, the retry test asserts registrations === 1 across five ticks, and the normalizer test pins null and {} as distinct return values including the fillPercentage: 0 case that a truthiness check would have dropped.

Everything else on the PR is green: ci / js, ci / python, CodeQL, CodeFactor, CodeRabbit, and all three compliance checks pass, and both Kilo threads are resolved.

Merge blocked by token scope. The app installation behind this run holds contents:read, issues:write, and pull_requests:write on this repo only. Merging writes the base branch, so both merge paths refuse:

GraphQL: Resource not accessible by integration (mergePullRequest)
HTTP 403 {"message":"Resource not accessible by integration"}   PUT /repos/.../pulls/17/merge
{"message":"Resource not accessible by integration"}             enablePullRequestAutoMerge

viewerCanMergeAsAdmin is false for this token, and squash-merge via REST returns the same 403, as does enabling auto-merge. A squash merge at fbe601c is all that's needed to land it.

ℹ️ Nitpicks

  • The comment in tests/screen.test.js describing why fakeEventBus replaces its handler list says fire() iterates the live list and a handler may (un)subscribe while it runs. Since on() and the disposer both set a new array rather than mutating, the for...of in fire() iterates the snapshot captured at entry — a handler that subscribes mid-dispatch is not picked up by the dispatch in progress. Behaviour is correct either way; only the stated rationale is off.

One follow-up worth deciding separately: because Codacy gates on new issues, the two Object Injection annotations at screen.js:128/:131 will recur on any future plugin payload this file consumes. Silencing them in Codacy rather than in code is the right call — no change to this two-line validation removes them without contorting it into Object.fromEntries gymnastics, and the code as written is the correct one.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using big-pickle (free) | 𝕏

Comment thread tests/screen.test.js
@pullfrog

pullfrog Bot commented Oct 5, 2026 •

Copy link
Copy Markdown
Author

Addressed the single open thread from review 5416996375. The nitpick was right: the fakeEventBus comment claimed fire() iterates the live handler list, but on() and the disposer both set a new array rather than mutating, so the for...of walks the snapshot captured when the loop starts. Rewrote the comment to state that accurately (commit 3c961a8, comment-only — no behaviour or test-assertion changes). node tests/screen.test.js still passes 35/35, pushed to the PR branch, thread replied to and resolved.

Nothing was left unaddressed — the re-read of the review at fresh: true showed the same single thread. The review body's other items were triage conclusions, not requests: the three Codacy Object Injection annotations were triaged as false positives with the code left as-is, and the two follow-ups it suggests (silencing the recurring annotations in Codacy rather than in code; a possible standards key in the capabilities block) are explicitly separate decisions that need someone to opt in, so I did not open issues for them here. Merge still requires a squash at 3c961a8; this run's token lacks the scope to do it.

Pullfrog  | View workflow run | via Pullfrog | Using big-pickle (free) | 𝕏

fire() reads the handler list once when the loop starts, and both on() and
the disposer replace the array instead of mutating it, so the iteration is
over the snapshot captured at entry. The previous wording claimed it
iterated the live list.
@cchacs
cchacs merged commit b4ad015 into main Oct 5, 2026
10 of 11 checks passed
@cchacs
cchacs deleted the pullfrog/13-harden-difficulty-ladder-peer-contract branch October 5, 2026 15:54
@cchacs

cchacs commented Oct 5, 2026

Copy link
Copy Markdown

@pullfrog please check issues were closed

@pullfrog

pullfrog Bot commented Oct 5, 2026 •

Copy link
Copy Markdown
Author

Checked every issue in the repo against what actually landed, and closed the one that was genuinely done.

#13 — closed as completed. It was still open because PR #17 said Implements #13, which GitHub links but does not treat as a closing keyword, so the merge left it stranded. Before closing I verified all five remediation items against the merged tree (b4ad015 on main), not the branch:

Item Status
Document the peer as optional README.md:24 — "Difficulty glasses (optional)", states navigation works standalone
Publish a v2 event-contract floor Published as v0.12.0, not the v0.9.13 in the issue text (that version appears never released) — documented as a floor, deliberately not enforced
Retry the capability check instead of trusting load order _smEnsureDifficultySubscription() runs from every 200 ms _smUpdate() tick
Contract tests for absent / late / minimum / current / malformed providers node tests/screen.test.js — 35/35 pass on main, up from 23
Feature-scoped optional dependency metadata capabilities.difficulty-ladder block in plugin.json

The closing comment records the two items deliberately not taken on — consuming the v3 payload, and adding a standards key — with where each one actually lives, so the boundary is on record rather than inferred later.

#14 — left open, deliberately. Its eight remediation items are untouched; the merged change hardened the existing v2 glass contract, whereas #14 is the rework that replaces it with a semantic tier rail. I added a short comment noting where the #13/#14 boundary sits, plus the two things worth knowing before scheduling it: the peer-side prerequisite (feedback-plugin-difficulty-ladder#131, since Difficulty Ladder has to stop emitting glassSize first), and the fact that the bounded v2 transition window is still open on the peer's side.

Repo state is now 1 closed (#13), 1 open (#14), and #1 / #5 already completed. No code changes, nothing to push.

Task list (5/5 completed)

Pullfrog  | View workflow run | via Pullfrog | Using big-pickle (free) | 𝕏

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant