Commits
Posts carried only a #link facet for the homepage URL, so the
#WordGame / #JeuDeMots / #atproto hashtags rendered as inert text
rather than real Bluesky tags.
Add tagFacets() to produce app.bsky.richtext.facet#tag facets (one per
hashtag; byte range over "#tag", tag value without the "#") and merge
them with the link facet, sorted by byte offset. The leading-letter
rule keeps the puzzle number "#N" from being mistaken for a tag.
Regression tests cover tag byte offsets (including multibyte) and that
"#3" is not tagged.
Distinct solver counting with honest sampling, explicit cron→lang
mapping, unknown-typed untrusted JSON, expanded test coverage (23→42),
and corrected SOLVER_SAMPLE_CAP docs.
The README told readers to raise SOLVER_SAMPLE_CAP to ~200 on the paid
plan, directly contradicting the 50-subrequest budget it had just
described: at ~2 subrequests per sampled record, 200 records is ~400
subrequests. Replace the figure with the per-record cost so the cap is
sized against the actual budget, and mirror the note in config.ts.
The two riskiest untested paths flagged by review. post.test.ts asserts
alreadyPosted matches today's marker, never cross-matches EN vs FR
markers for the same puzzle, and allows the post when the listRecords
read fails. constellation.test.ts exercises collectBacklinks cursor
following, cap slicing with the truncated flag, and mid-walk page
failure surfacing as truncated.
get() returned any, so every downstream field access (data.total,
data.records, data.cursor) was unchecked at the one boundary where
external, untrusted JSON enters the worker — neutralizing the strict
tsconfig for that path. Return unknown and narrow each field at the
call site; this also makes getBacklinks reject a non-string cursor
instead of passing it through.
langForCron used a 'starts with 11 ' prefix test that mapped every other
cron expression to English. Any trigger change (a retimed FR cron, an
added trigger, a malformed __scheduled?cron= request) would silently
post English and skip French. Map the two configured expressions
explicitly and throw on anything unrecognized.
Adversarial review found the public congrats copy could report wrong
numbers:
- solvers was counted per result-record, so a player with two records
was counted twice, and solvers could exceed the distinct-DID players
count, yielding copy like "Congrats to the 25" when only 20 played.
- the exact "X solved, Y didn't" branch subtracted a full-population
players count from a capped/lossy solvers sample, so a truncated page
or a dropped record produced an inflated non-solver number.
- the sampled hedge was keyed on raw record count, over-hedging at
exactly the cap and never reflecting partial pagination.
Now solvers is the count of distinct winning DIDs, clamped to players;
collectBacklinks fetches cap+1 and reports truncated; and yesterdayCounts
marks the result sampled whenever the sample is incomplete (truncated,
null player count, or fewer distinct players resolved than counted).
compose no longer claims "nobody cracked it" on an incomplete sample.
Adds test/counts.test.ts covering dedupe, clamping, lang/puzzle
filtering, the sampled boundary, dropped reads, and null players.
Add three I/O modules: constellation.ts (backlink-index HTTP client),
identity.ts (DID→PDS resolution + per-PDS record reads), and counts.ts
(yesterdayCounts combining both). Also install @atcute/atproto and add
it to tsconfig types so XRPCQueries is populated for getRecord calls.
Posts carried only a #link facet for the homepage URL, so the
#WordGame / #JeuDeMots / #atproto hashtags rendered as inert text
rather than real Bluesky tags.
Add tagFacets() to produce app.bsky.richtext.facet#tag facets (one per
hashtag; byte range over "#tag", tag value without the "#") and merge
them with the link facet, sorted by byte offset. The leading-letter
rule keeps the puzzle number "#N" from being mistaken for a tag.
Regression tests cover tag byte offsets (including multibyte) and that
"#3" is not tagged.
The README told readers to raise SOLVER_SAMPLE_CAP to ~200 on the paid
plan, directly contradicting the 50-subrequest budget it had just
described: at ~2 subrequests per sampled record, 200 records is ~400
subrequests. Replace the figure with the per-record cost so the cap is
sized against the actual budget, and mirror the note in config.ts.
The two riskiest untested paths flagged by review. post.test.ts asserts
alreadyPosted matches today's marker, never cross-matches EN vs FR
markers for the same puzzle, and allows the post when the listRecords
read fails. constellation.test.ts exercises collectBacklinks cursor
following, cap slicing with the truncated flag, and mid-walk page
failure surfacing as truncated.
get() returned any, so every downstream field access (data.total,
data.records, data.cursor) was unchecked at the one boundary where
external, untrusted JSON enters the worker — neutralizing the strict
tsconfig for that path. Return unknown and narrow each field at the
call site; this also makes getBacklinks reject a non-string cursor
instead of passing it through.
langForCron used a 'starts with 11 ' prefix test that mapped every other
cron expression to English. Any trigger change (a retimed FR cron, an
added trigger, a malformed __scheduled?cron= request) would silently
post English and skip French. Map the two configured expressions
explicitly and throw on anything unrecognized.
Adversarial review found the public congrats copy could report wrong
numbers:
- solvers was counted per result-record, so a player with two records
was counted twice, and solvers could exceed the distinct-DID players
count, yielding copy like "Congrats to the 25" when only 20 played.
- the exact "X solved, Y didn't" branch subtracted a full-population
players count from a capped/lossy solvers sample, so a truncated page
or a dropped record produced an inflated non-solver number.
- the sampled hedge was keyed on raw record count, over-hedging at
exactly the cap and never reflecting partial pagination.
Now solvers is the count of distinct winning DIDs, clamped to players;
collectBacklinks fetches cap+1 and reports truncated; and yesterdayCounts
marks the result sampled whenever the sample is incomplete (truncated,
null player count, or fewer distinct players resolved than counted).
compose no longer claims "nobody cracked it" on an incomplete sample.
Adds test/counts.test.ts covering dedupe, clamping, lang/puzzle
filtering, the sampled boundary, dropped reads, and null players.