Skip to content

Serve the real catalogue, and walk a referral graph in the wild - #9

Merged
HarryCordewener merged 1 commit into
mainfrom
feat/real-data-and-referral-crawl
Jul 31, 2026
Merged

Serve the real catalogue, and walk a referral graph in the wild#9
HarryCordewener merged 1 commit into
mainfrom
feat/real-data-and-referral-crawl

Conversation

@HarryCordewener

@HarryCordewener HarryCordewener commented Jul 31, 2026

Copy link
Copy Markdown
Member

The site read a fixture; it now reads PostgreSQL when MUI_POSTGRES (or ConnectionStrings:MUIndex) names one, applies migrations at startup, and falls back to the fixture only when neither is set — announced in the log and, more importantly, on every page. A directory whose whole claim is that its data is measured cannot show invented data silently.

What a real crawl found that the fixture could not

Sixteen live games, one probe each. Three sentences were lying, and all three are the house rule read forward into prose:

Was Is Why it mattered
Reachable 100.0% of the last 90 days Reachable 100.0% of the 1 day we have measured The arithmetic was always right — the denominator is observed time. The sentence widened it to the window.
167 hours … could not be measured — the game was not reachable 167 hours … have no measurement yet Said about a game measured once and found perfectly reachable. A failed probe writes no presence row, so an empty cell cannot tell an outage of theirs from a gap of ours.
BANNER_HASH 301233d35aed… under what the game says about itself gone A digest we compute, 64 hex characters, off the edge of its column. Not in the field registry, so nothing registry-shaped would have caught it.

Referral discovery, walked in the wild

Of 141 live servers — the codebase survey plus every entry in TinTin's MSSP crawler list — two publish REFERRAL, and both name tbamud.com, one of them two ports of it. Walking it found two more defects:

  • A NUL byte from the wire aborted a game's whole ingestion. PostgreSQL text cannot hold 0x00, so the first field carrying one raised 22021 mid-write: game half-created, endpoint never scheduled, REFERRAL list never read. RFC 854 makes CR NUL a bare CR, so the byte is telnet framing; it is stripped at both doors into the crawler. It was also the cheapest DoS a hostile server had.
  • tbaMUD says "Attempting to Detect Client, Please Wait…", pauses, then paints. Settling on a gap stored the placeholder as the banner and its hash as the identity, so two unrelated tbaMUDs fingerprinted alike and the second went into a duplicate review instead of onto the site. NAME "PennMUSH" again, one field over. The phase now waits longer only when what it has is slight and has not reached a prompt.

Both halves of §7.2's gate are pinned end to end: a referred host that names itself becomes a second listed game without being seeded; one that answers with only its codebase's name does not. tbamud.com is the second case, live — discovered, probed, kept, correctly unlisted.

Also

  • Backfill data does not live in this repository (spec §7.6, CLAUDE.md). The importer is code; a harvested catalogue is not, and the import is one command an operator runs once against the one deployment.
  • Screenshots retaken against the real catalogue.

721 tests, six suites, zero warnings.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NrGKmKcRCGktyhRTFbQDMk

Summary by CodeRabbit

  • New Features

    • Added PostgreSQL-backed catalogue support with automatic migrations when configured.
    • Added a persistent demo-data banner when live catalogue data is unavailable.
    • Improved banner capture and text cleaning for more reliable game discovery.
  • Bug Fixes

    • Clarified activity reporting so missing measurements are not presented as unreachable.
    • Reachability percentages now use only observed measurement periods.
    • Internal catalogue fields are excluded from game-reported metadata.
  • Documentation

    • Updated usage, data-quality, screenshot, and measurement guidance.
    • Added protections against accidentally committing imported datasets.

The site read a fixture. It now reads PostgreSQL when MUI_POSTGRES (or
ConnectionStrings:MUIndex) names one, applies migrations at startup, and falls
back to the fixture only when neither is set -- announced in the log and, more
importantly, on every page. A directory whose whole claim is that its data is
measured cannot show invented data silently; the demo banner is not dismissible
and sits above the mark, because a reader who cannot tell a measurement from a
fixture is being misled by the exact mechanism this project exists to replace.

Pointing it at sixteen live games immediately found three sentences the fixture
could not, and all three are the house rule read forward into prose:

  - "Reachable 100.0% of the last 90 days" off a single probe. The arithmetic
    was always right -- FractionReachable divides by observed time -- and the
    sentence widened it to the window. It now names the days it measured, and
    keeps the short phrasing only when all ninety were.
  - "167 hours across the week could not be measured -- the game was not
    reachable", about a game measured once and found perfectly reachable. A
    failed probe writes no presence row, so an empty cell covers an hour we
    could not reach and an hour we never probed alike. Every surface now says
    "no measurement", never "not reachable"; the strip answers reachability,
    from intervals that can tell the two apart.
  - banner_hash, a digest we compute, rendered as sixty-four hex characters at
    the top of "what the game says about itself". InternalFields is the list,
    and it is a list rather than a convention because the old rule was a
    connect_screen prefix inlined at the call site and banner_hash is not in the
    field registry at all.

Then the referral crawl, which is its own answer. Of 141 live servers -- the
codebase survey plus every entry in TinTin's MSSP crawler list -- two publish
REFERRAL, and both name tbamud.com, one of them two ports of it. Walking that
found two more defects that no amount of reading would have:

  - A NUL byte from the wire aborted a game's whole ingestion. PostgreSQL text
    cannot hold 0x00, so the first field carrying one raised 22021 mid-write:
    the game was left half-created, its endpoint never scheduled, and its
    REFERRAL list never read. RFC 854 makes CR NUL the way to send a bare CR, so
    the byte is telnet framing rather than content, and it is stripped where
    text enters the crawler -- both doors, the line reader and the
    subnegotiation. It is also the cheapest denial of service a hostile server
    had.
  - tbaMUD sends "Attempting to Detect Client, Please Wait...", pauses, and then
    paints. Settling on a gap between lines stored that placeholder as the
    banner and its hash as the identity, so two unrelated tbaMUDs fingerprinted
    alike and the second went into a duplicate review instead of onto the site.
    That is NAME "PennMUSH" again one field over. The connect-screen phase now
    waits longer when what it has is slight and has not reached a prompt --
    conditional, so a server that has already painted pays nothing.

Both halves of the listing gate are pinned end to end now: a referred host that
names itself becomes a second listed game without being seeded, and one that
answers with only its codebase's name does not. tbamud.com is the second case,
live -- discovered, probed, kept, and correctly unlisted.

Screenshots are retaken against the real catalogue. The old ones were honest
about being a fixture; these are the site's first day, which is the state every
real game enters in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

The PR adds PostgreSQL catalogue wiring, demo-source reporting, shared crawler text normalization, delayed banner handling, internal field filtering, referral validation, and clearer measured versus unmeasured activity reporting.

Changes

Catalogue and reporting

Layer / File(s) Summary
Measurement states and reporting
CLAUDE.md, README.md, docs/screenshots/README.md, src/MUI.Catalog/Views.cs, src/MUI.Web/Components/*, tests/MUI.Web.Tests/*
Activity output and documentation distinguish measured, unmeasurable, and unmeasured states. Reachability percentages use measured days when coverage is partial.
Banner and wire normalization
src/MUI.Crawl/*, src/MUI.Discovery/BannerFingerprint.cs, tests/MUI.Crawl.Tests/*
Shared banner normalization removes terminal escapes and control characters. Wire cleaning removes NUL bytes. Short banners receive bounded additional patience.
Internal catalogue fields
src/MUI.Catalog/Persistence/*, src/MUI.Discovery/Identity.cs, tests/MUI.Web.Tests/SilenceIsNotEvidenceTests.cs
Catalogue-owned fields include banner_hash and connect_screen fields. Game-reported metadata excludes these fields.
PostgreSQL catalogue and demo source
src/MUI.Web/Data/PostgresData.cs, src/MUI.Web/Program.cs, src/MUI.Web/Components/Layout/MainLayout.razor, src/MUI.Web/wwwroot/app.css, docs/specs/*, docs/screenshots/README.md
The application uses PostgreSQL when configured and fixture services otherwise. Fixture mode exposes a persistent demo banner. Startup applies migrations.
Referral discovery validation
tests/MUI.Crawler.Tests/CrawlCyclePostgresTests.cs, README.md, docs/specs/*
Referral targets are probed and retained, then listed only after they provide a usable identity.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Program
  participant PostgresData
  participant MigrationRunner
  participant MainLayout
  Program->>PostgresData: resolve configured PostgreSQL connection
  Program->>PostgresData: register measured catalogue services
  PostgresData->>MigrationRunner: apply database migrations
  MainLayout->>MainLayout: display demo banner when fixture data is active
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 51.02% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the two primary changes: serving the real catalogue and traversing referral links during crawling.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 8

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/screenshots/README.md`:
- Around line 3-16: Remove the committed live-crawl provenance and generated
screenshot assets referenced by docs/screenshots/README.md, while preserving
only an operator-local reproduction procedure. In README.md lines 9-12, remove
the link and claims publishing the live-crawl screenshot set; do not replace
them with generated catalogue data or screenshot references.
- Around line 51-55: Specify the fenced Markdown block language by changing the
opening delimiter in the documentation around the counted/uncounted/no data
example to use text, while leaving the block contents unchanged.

In `@src/MUI.Catalog/Views.cs`:
- Around line 64-76: Replace the forbidden “uptime” term in the XML
documentation around the presence-row behavior with “reachable time” or
“reachability,” while preserving the existing explanation and meaning.

In `@src/MUI.Crawl/BannerText.cs`:
- Around line 64-108: Update SkipEscape for non-CSI, non-OSC escape sequences so
it consumes 0x20–0x2F intermediate bytes and the following final byte,
preventing characters such as B or 0 from reaching BannerText.Flatten. Add SCS
coverage for ESC ( B and ESC ( 0, including the resulting BannerFingerprint and
LooksUnfinished behavior.

In `@src/MUI.Web/Components/ActivitySummary.cs`:
- Around line 71-73: Update the XML remarks for ActivitySummary.CellValue and
ActivityCell.IsGap to describe the IsGap state as “not measured” instead of “not
reached,” matching the current CellValue implementation while preserving the
existing documentation for other states.

In `@src/MUI.Web/Components/ReachSeries.cs`:
- Around line 84-103: The ReachSummary.Sentence percentage currently uses
observed elapsed time while its wording uses measured calendar days, creating
mismatched denominators. Update the Reachability summary flow around
ReachableFraction and Sentence to use observed-time wording consistent with the
fraction (or change the fraction to measured-day calculation), preserving the
full-window wording behavior; add coverage for a window containing reachable,
unreachable, and unmeasured days.

In `@src/MUI.Web/Data/PostgresData.cs`:
- Around line 63-71: Update MigrationRunner.ApplyAsync to acquire a PostgreSQL
database-wide advisory lock before reading pending migrations and hold it
through all migration DDL and ledger writes, releasing it only after the
complete run finishes. Ensure ApplyMigrationsAsync continues using this
serialized migration path and preserves the existing applied-migration logging.

In `@tests/MUI.Crawl.Tests/ProbeSessionTests.cs`:
- Around line 184-194: Move the “A MU* server...” XML summary from its current
position to immediately above the private sealed FakeGame class declaration,
leaving the readiness-test summary attached to
AScreenThatSaidItWasNotReadyIsWaitedFor and preserving both descriptions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: db48d66e-9bd7-4848-9ad1-21ecc68f157d

📥 Commits

Reviewing files that changed from the base of the PR and between 2caff7f and 20c69ee.

⛔ Files ignored due to path filters (6)
  • docs/screenshots/01-home-feeds.png is excluded by !**/*.png
  • docs/screenshots/02-games-listing.png is excluded by !**/*.png
  • docs/screenshots/03-game-page.png is excluded by !**/*.png
  • docs/screenshots/04-plain-mode.png is excluded by !**/*.png
  • docs/screenshots/05-archive.png is excluded by !**/*.png
  • docs/screenshots/06-mobile-game.png is excluded by !**/*.png
📒 Files selected for processing (29)
  • .gitignore
  • CLAUDE.md
  • README.md
  • docs/screenshots/README.md
  • docs/specs/2026-07-30-mu-directory-design.md
  • src/MUI.Catalog/Persistence/FieldRegistry.cs
  • src/MUI.Catalog/Persistence/NpgsqlGameQueries.cs
  • src/MUI.Catalog/Views.cs
  • src/MUI.Crawl/BannerText.cs
  • src/MUI.Crawl/MsspReport.cs
  • src/MUI.Crawl/ProbeOptions.cs
  • src/MUI.Crawl/TelnetProbe.cs
  • src/MUI.Crawl/WireText.cs
  • src/MUI.Discovery/BannerFingerprint.cs
  • src/MUI.Discovery/Identity.cs
  • src/MUI.Web/Components/ActivityHeatmap.razor
  • src/MUI.Web/Components/ActivitySummary.cs
  • src/MUI.Web/Components/Layout/MainLayout.razor
  • src/MUI.Web/Components/PlainText.cs
  • src/MUI.Web/Components/ReachSeries.cs
  • src/MUI.Web/Data/PostgresData.cs
  • src/MUI.Web/Program.cs
  • src/MUI.Web/wwwroot/app.css
  • tests/MUI.Crawl.Tests/ProbeSessionTests.cs
  • tests/MUI.Crawl.Tests/WireTextTests.cs
  • tests/MUI.Crawler.Tests/CrawlCyclePostgresTests.cs
  • tests/MUI.Web.Tests/PlainParityTests.cs
  • tests/MUI.Web.Tests/SilenceIsNotEvidenceTests.cs
  • tests/MUI.Web.Tests/ThreeStatesTests.cs

Comment on lines +3 to 16
Captured from the running site against **a real PostgreSQL catalogue**, populated by `mui-crawl`
against live servers: sixteen games, one probe each. Nothing here is a fixture. The site prefers a
database and falls back to the demo fixture only when none is configured — and when it does, every
page carries a banner saying so, because a reader who cannot tell a measurement from a fixture is
being misled by exactly the mechanism this project exists to replace.

| File | What it shows |
|---|---|
| `01-home-feeds.png` | The three liveness feeds. The *came back* card is the one place the site raises its voice. |
| `02-games-listing.png` | The listing, with a measured zero, an unknown count and an archived game distinguishable at a glance. |
| `03-game-page.png` | The whole game page: ANSI frame, heatmap, reachable strip, capability matrix, provenance. |
| `01-home-feeds.png` | The three liveness feeds, off a first crawl: sixteen games *newly discovered*. |
| `02-games-listing.png` | The listing. Measured counts, measured zeroes, and games whose handshake offered nothing. |
| `03-game-page.png` | Virtustan MUD — the whole game page, and the game that led the crawler to two more endpoints. |
| `04-plain-mode.png` | `?plain=1` — the same facts as words. |
| `05-archive.png` | The archive as a section rather than a bin. |
| `05-archive.png` | The archive. Empty, and saying so: nothing has been dark long enough. |
| `06-mobile-game.png` | 390px. Single column, heatmap keeps all 168 cells. |

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Remove the committed live-crawl catalogue snapshot.

The screenshots and their README contain live measurements. They are not hand-written test fixtures. Keep an operator-local reproduction procedure, but do not retain or link generated live-catalogue output.

  • docs/screenshots/README.md#L3-L16: Remove the live-crawl screenshot provenance and remove the generated screenshot assets from version control.
  • README.md#L9-L12: Remove the link and claims that publish the committed live-crawl screenshot set.

As per coding guidelines, “Do not commit harvested datasets, mirrored listings, scraped seed lists, or catalogue snapshots.”

📍 Affects 2 files
  • docs/screenshots/README.md#L3-L16 (this comment)
  • README.md#L9-L12
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/screenshots/README.md` around lines 3 - 16, Remove the committed
live-crawl provenance and generated screenshot assets referenced by
docs/screenshots/README.md, while preserving only an operator-local reproduction
procedure. In README.md lines 9-12, remove the link and claims publishing the
live-crawl screenshot set; do not replace them with generated catalogue data or
screenshot references.

Source: Coding guidelines

Comment on lines 51 to 55
```
Wed — peak 13 at 20:00, 12 hours not reachable
Fri — peak 16 at 20:00, nobody on 05:00-11:59, 1 hour probed but uncountable

counted = we got in and read a number, including a measured zero
uncounted = we got in and no number could be read
no data = we could not reach the game in that hour at all
no data = we have no measurement for that hour
```

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Specify the fenced-block language.

Markdownlint reports MD040 for this fence. Add text to the opening delimiter.

Proposed fix
-```
+```text
   counted   = we got in and read a number, including a measured zero
   uncounted = we got in and no number could be read
   no data   = we have no measurement for that hour
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
```
Wed — peak 13 at 20:00, 12 hours not reachable
Fri — peak 16 at 20:00, nobody on 05:00-11:59, 1 hour probed but uncountable
counted = we got in and read a number, including a measured zero
uncounted = we got in and no number could be read
no data = we could not reach the game in that hour at all
no data = we have no measurement for that hour
```
🧰 Tools
🪛 markdownlint-cli2 (0.23.1)

[warning] 51-51: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/screenshots/README.md` around lines 51 - 55, Specify the fenced Markdown
block language by changing the opening delimiter in the documentation around the
counted/uncounted/no data example to use text, while leaving the block contents
unchanged.

Source: Linters/SAST tools

Comment thread src/MUI.Catalog/Views.cs
Comment on lines +64 to +76
/// <summary>
/// No measurement for that hour. Empty — and emphatically not a zero.
/// </summary>
/// <remarks>
/// <b>Not measured, not "not reachable".</b> A presence row exists only when a probe got far
/// enough to try counting, and a probe that failed writes no presence row at all — it goes to
/// the availability writer instead (see <c>PresenceWriter</c>'s own remarks). So silence here
/// covers both an hour we could not reach and an hour we never probed, and those are different
/// facts about a game. Rendering silence as unreachability states our own gap as their outage,
/// which is the one thing this site may never do: a game found an hour ago would have 167 hours
/// of a perfect week's uptime described as downtime. Reachability has its own strip, measured
/// from intervals that can tell the difference.
/// </remarks>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Replace the forbidden uptime term.

Line 74 uses uptime. Replace it with reachable time or reachability.

As per coding guidelines, use the term reachable, never uptime, in schema, API, code, and copy.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/MUI.Catalog/Views.cs` around lines 64 - 76, Replace the forbidden
“uptime” term in the XML documentation around the presence-row behavior with
“reachable time” or “reachability,” while preserving the existing explanation
and meaning.

Source: Coding guidelines

Comment on lines +64 to +108
/// <summary>The index of the last character of the escape sequence starting at <paramref name="start"/>.</summary>
private static int SkipEscape(string banner, int start)
{
var i = start + 1;
if (i >= banner.Length)
{
return start;
}

// CSI: ESC [ … final byte in 0x40–0x7E.
if (banner[i] == '[')
{
for (i++; i < banner.Length; i++)
{
if (banner[i] is >= '@' and <= '~')
{
return i;
}
}

return banner.Length - 1;
}

// OSC: ESC ] … BEL, or ESC ] … ESC \.
if (banner[i] == ']')
{
for (i++; i < banner.Length; i++)
{
if (banner[i] == '\a')
{
return i;
}

if (banner[i] == '\e' && i + 1 < banner.Length && banner[i + 1] == '\\')
{
return i + 1;
}
}

return banner.Length - 1;
}

// Anything else two-byte: ESC 7, ESC =, ESC ( B and friends.
return i;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- required design and survey files ---'
for f in docs/specs/2026-07-30-mu-directory-design.md docs/codebase-survey-2026-07-30.md; do
  if [ -f "$f" ]; then
    wc -l "$f"
    sed -n '1,220p' "$f"
  else
    printf 'MISSING %s\n' "$f"
  fi
done
printf '%s\n' '--- candidate file map ---'
ast-grep outline src/MUI.Crawl/BannerText.cs --lang csharp
printf '%s\n' '--- BannerText.cs ---'
cat -n src/MUI.Crawl/BannerText.cs
printf '%s\n' '--- related symbols and tests ---'
rg -n --glob '!bin/**' --glob '!obj/**' 'SkipEscape|Flatten|BannerFingerprint|LooksUnfinished|BannerPatience' .

Repository: SharpMUSH/MUIndex

Length of output: 42661


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- probe heuristic and fingerprint implementation ---'
cat -n src/MUI.Crawl/TelnetProbe.cs | sed -n ' eighty,225p' | sed 's/^/ /' || true
cat -n src/MUI.Discovery/BannerFingerprint.cs
printf '%s\n' '--- focused tests ---'
cat -n tests/MUI.Discovery.Tests/BannerFingerprintTests.cs
printf '%s\n' '--- design references to banner normalization and patience ---'
rg -n -C 4 'BannerFingerprint|BannerPatience|connect screen|escape|ANSI|SCS|character.?set' \
  docs/specs/2026-07-30-mu-directory-design.md \
  docs/superpowers/plans/2026-07-30-03-discovery-scheduling-identity.md
printf '%s\n' '--- behavioral probe: current SkipEscape/Flatten semantics versus SCS-aware semantics ---'
python3 - <<'PY'
import hashlib
import unicodedata

def skip_current(s, start):
    i = start + 1
    if i >= len(s):
        return start
    if s[i] == '[':
        i += 1
        while i < len(s):
            if '@' <= s[i] <= '~':
                return i
            i += 1
        return len(s) - 1
    if s[i] == ']':
        i += 1
        while i < len(s):
            if s[i] == '\a':
                return i
            if s[i] == '\x1b' and i + 1 < len(s) and s[i+1] == '\\':
                return i + 1
            i += 1
        return len(s) - 1
    return i

def skip_proposed(s, start):
    i = start + 1
    if i >= len(s):
        return start
    if s[i] == '[':
        i += 1
        while i < len(s):
            if '@' <= s[i] <= '~':
                return i
            i += 1
        return len(s) - 1
    if s[i] == ']':
        i += 1
        while i < len(s):
            if s[i] == '\a':
                return i
            if s[i] == '\x1b' and i + 1 < len(s) and s[i+1] == '\\':
                return i + 1
            i += 1
        return len(s) - 1
    while i < len(s):
        if not (' ' <= s[i] <= '/'):
            return i
        i += 1
    return len(s) - 1

def flatten(s, skipper):
    out, pending = [], False
    i = 0
    while i < len(s):
        ch = s[i]
        if ch == '\x1b':
            i = skipper(s, i)
        elif ch.isspace():
            pending = bool(out)
        elif unicodedata.category(ch)[0] == 'C':
            pass
        else:
            if pending:
                out.append(' ')
                pending = False
            out.append(ch)
        i += 1
    return ''.join(out)

cases = {
    'SCS ASCII': '\x1b(BWelcome',
    'SCS line drawing': '\x1b(0Welcome',
    'SCS with multiple intermediates': '\x1b 0AWelcome',
    'two-byte ESC 7': '\x1b7Welcome',
    'two-byte ESC =': '\x1b=Welcome',
    'CSI': '\x1b[31mWelcome\x1b[0m',
    'OSC BEL': '\x1b]0;title\aWelcome',
}
for name, value in cases.items():
    old = flatten(value, skip_current)
    new = flatten(value, skip_proposed)
    print(f'{name}: current={old!r}, proposed={new!r}, changed={old != new}')
print('SCS fingerprint:', hashlib.sha256(flatten(cases['SCS ASCII'], skip_current).encode()).hexdigest(),
      hashlib.sha256(flatten(cases['SCS ASCII'], skip_proposed).encode()).hexdigest())
PY

Repository: SharpMUSH/MUIndex

Length of output: 50375


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- TelnetProbe heuristic ---'
sed -n '80,220p' src/MUI.Crawl/TelnetProbe.cs
printf '%s\n' '--- compact design implementation excerpt ---'
sed -n '2088,2135p' docs/superpowers/plans/2026-07-30-03-discovery-scheduling-identity.md
printf '%s\n' '--- behavioral probe ---'
python3 - <<'PY'
import hashlib
import unicodedata

def skip(s, start, scs):
    i = start + 1
    if i >= len(s):
        return start
    if s[i] == '[':
        i += 1
        while i < len(s):
            if '@' <= s[i] <= '~':
                return i
            i += 1
        return len(s) - 1
    if s[i] == ']':
        i += 1
        while i < len(s):
            if s[i] == '\a' or (s[i] == '\x1b' and i + 1 < len(s) and s[i+1] == '\\'):
                return i + (1 if s[i] == '\x1b' else 0)
            i += 1
        return len(s) - 1
    if not scs:
        return i
    while i < len(s) and ' ' <= s[i] <= '/':
        i += 1
    return min(i, len(s) - 1)

def flatten(s, scs):
    out = []
    pending = False
    i = 0
    while i < len(s):
        ch = s[i]
        if ch == '\x1b':
            i = skip(s, i, scs)
        elif ch.isspace():
            pending = bool(out)
        elif unicodedata.category(ch)[0] == 'C':
            pass
        else:
            if pending:
                out.append(' ')
                pending = False
            out.append(ch)
        i += 1
    return ''.join(out)

cases = [
    ('SCS ASCII', '\x1b(BWelcome'),
    ('SCS line drawing', '\x1b(0Welcome'),
    ('SCS multiple intermediates', '\x1b 0AWelcome'),
    ('ESC 7', '\x1b7Welcome'),
    ('ESC =', '\x1b=Welcome'),
    ('CSI', '\x1b[31mWelcome\x1b[0m'),
    ('OSC BEL', '\x1b]0;title\aWelcome'),
]
for name, value in cases:
    old = flatten(value, False)
    new = flatten(value, True)
    print(f'{name}: current={old!r}; SCS-aware={new!r}; changed={old != new}')
value = '\x1b(BWelcome'
print('hashes:', hashlib.sha256(flatten(value, False).encode()).hexdigest(),
      hashlib.sha256(flatten(value, True).encode()).hexdigest())
PY

Repository: SharpMUSH/MUIndex

Length of output: 8844


Consume intermediate bytes in SkipEscape. ESC ( B and ESC ( 0 currently leave B or 0 in BannerText.Flatten, changing BannerFingerprint and LooksUnfinished results. Skip 0x20–0x2F intermediate bytes before the final byte, and add SCS coverage.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/MUI.Crawl/BannerText.cs` around lines 64 - 108, Update SkipEscape for
non-CSI, non-OSC escape sequences so it consumes 0x20–0x2F intermediate bytes
and the following final byte, preventing characters such as B or 0 from reaching
BannerText.Flatten. Add SCS coverage for ESC ( B and ESC ( 0, including the
resulting BannerFingerprint and LooksUnfinished behavior.

Comment on lines +71 to 73
{ IsGap: true } => "not measured",
{ IsUnmeasurable: true } => "not counted",
_ => cell.Count!.Value.ToString(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Align the CellValue documentation with the new state.

Line 71 now returns "not measured" for IsGap, but the XML remarks immediately above still describe the state as "not reached" on Lines 66-67. Update the remarks so ActivitySummary.CellValue and ActivityCell.IsGap document the same contract.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/MUI.Web/Components/ActivitySummary.cs` around lines 71 - 73, Update the
XML remarks for ActivitySummary.CellValue and ActivityCell.IsGap to describe the
IsGap state as “not measured” instead of “not reached,” matching the current
CellValue implementation while preserving the existing documentation for other
states.

Comment on lines 84 to +103
var bad = Days.Count(d => d.State is ReachState.Unreachable);
var degraded = Days.Count(d => d.State is ReachState.Degraded);
var unmeasured = Days.Count(d => d.State is ReachState.Unmeasured);
var measured = Window - unmeasured;

// The percentage's denominator is *observed* time, not the window (Reachability
// .FractionReachable). The sentence has to say so, or a game found an hour ago reads
// "Reachable 100.0% of the last 90 days" off a single successful probe — a true number
// wearing a claim eighty-nine days wider than the evidence. Only when the window is
// fully measured do the two denominators coincide and the shorter phrasing become true.
parts.Add(ReachableFraction is { } f
? unmeasured == 0
? $"Reachable {Wording.Percent(f)} of the last {Window} days."
: $"Reachable {Wording.Percent(f)} of the {DayCount(measured)} we have measured."
: $"Reachability over the last {Window} days is not yet measured.");

parts.Add(bad == 0
? "No day in the window was unreachable."
? unmeasured == 0
? "No day in the window was unreachable."
: "No day we measured was unreachable."

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

rg -n -C 12 \
  'FractionReachable|ReachSeries\.Build|new ReachSummary|ReachSummary\(' \
  src tests --glob '*.cs'

Repository: SharpMUSH/MUIndex

Length of output: 36025


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf '%s\n' '--- authoritative design ---'
sed -n '1,220p' docs/specs/2026-07-30-mu-directory-design.md
printf '%s\n' '--- codebase survey ---'
sed -n '1,220p' docs/codebase-survey-2026-07-30.md
printf '%s\n' '--- reachability arithmetic ---'
sed -n '68,145p' src/MUI.Catalog/Availability.cs
printf '%s\n' '--- reach series build ---'
sed -n '160,240p' src/MUI.Web/Components/ReachSeries.cs
printf '%s\n' '--- related tests ---'
sed -n '24,75p' tests/MUI.Catalog.Tests/AvailabilityContractTests.cs
sed -n '24,48p' tests/MUI.Web.Tests/SilenceIsNotEvidenceTests.cs

Repository: SharpMUSH/MUIndex

Length of output: 35556


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf '%s\n' '--- sentence and day-state logic ---'
sed -n '61,160p' src/MUI.Web/Components/ReachSeries.cs
printf '%s\n' '--- mixed-window test helpers ---'
sed -n '1,30p' tests/MUI.Catalog.Tests/AvailabilityContractTests.cs
sed -n '1,45p' tests/MUI.Web.Tests/SilenceIsNotEvidenceTests.cs

printf '%s\n' '--- deterministic denominator probe ---'
python3 - <<'PY'
from datetime import datetime, timedelta

now = datetime(2026, 7, 31, 12)
window_start = now - timedelta(days=90)

# Each tuple is (state, start, end), with end=None meaning now.
intervals = [
    ("unreachable", now - timedelta(days=3, hours=23), now - timedelta(days=3, hours=22)),
    ("reachable", now - timedelta(days=1), now),
]

observed = timedelta()
reachable = timedelta()
for state, start, end in intervals:
    end = end or now
    start = max(start, window_start)
    end = min(end, now)
    if end > start:
        observed += end - start
        if state == "reachable":
            reachable += end - start

# ReachSeries.Build marks a calendar day measured if any clipped interval overlaps it.
measured_days = set()
for state, start, end in intervals:
    end = end or now
    cursor = max(start, window_start).date()
    last = min(end, now).date()
    while cursor <= last:
        day_start = datetime.combine(cursor, datetime.min.time())
        day_end = min(day_start + timedelta(days=1), now)
        if min(end, day_end) > max(start, day_start):
            measured_days.add(cursor)
        cursor += timedelta(days=1)

print(f"observed_hours={observed.total_seconds()/3600:g}")
print(f"reachable_hours={reachable.total_seconds()/3600:g}")
print(f"fraction_reachable={reachable.total_seconds()/observed.total_seconds():.6f}")
print(f"measured_days={len(measured_days)}")
print(f"sentence_denominator_days={len(measured_days)}")
PY

Repository: SharpMUSH/MUIndex

Length of output: 7239


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf '%s\n' '--- sentence and day-state logic ---'
sed -n '61,160p' src/MUI.Web/Components/ReachSeries.cs
printf '%s\n' '--- mixed-window test helpers ---'
sed -n '1,30p' tests/MUI.Catalog.Tests/AvailabilityContractTests.cs
sed -n '1,45p' tests/MUI.Web.Tests/SilenceIsNotEvidenceTests.cs

printf '%s\n' '--- deterministic denominator probe ---'
python3 - <<'PY'
from datetime import datetime, timedelta

now = datetime(2026, 7, 31, 12)
window_start = now - timedelta(days=90)

intervals = [
    ("unreachable", now - timedelta(days=3, hours=23), now - timedelta(days=3, hours=22)),
    ("reachable", now - timedelta(days=1), now),
]

observed = timedelta()
reachable = timedelta()
for state, start, end in intervals:
    end = end or now
    start = max(start, window_start)
    end = min(end, now)
    if end > start:
        observed += end - start
        if state == "reachable":
            reachable += end - start

measured_days = set()
for state, start, end in intervals:
    end = end or now
    cursor = max(start, window_start).date()
    last = min(end, now).date()
    while cursor <= last:
        day_start = datetime.combine(cursor, datetime.min.time())
        day_end = min(day_start + timedelta(days=1), now)
        if min(end, day_end) > max(start, day_start):
            measured_days.add(cursor)
        cursor += timedelta(days=1)

print(f"observed_hours={observed.total_seconds()/3600:g}")
print(f"reachable_hours={reachable.total_seconds()/3600:g}")
print(f"fraction_reachable={reachable.total_seconds()/observed.total_seconds():.6f}")
print(f"measured_days={len(measured_days)}")
print(f"sentence_denominator_days={len(measured_days)}")
PY

Repository: SharpMUSH/MUIndex

Length of output: 7239


Use the same denominator for the percentage and wording

Reachability.FractionReachable uses observed elapsed time, but ReachSummary.Sentence names measured calendar days. A mixed window can therefore report, for example, 96% of 25 observed hours as “of the 3 days we have measured.” Use observed-time wording or calculate the fraction by measured days. Add a test with reachable, unreachable, and unmeasured days.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/MUI.Web/Components/ReachSeries.cs` around lines 84 - 103, The
ReachSummary.Sentence percentage currently uses observed elapsed time while its
wording uses measured calendar days, creating mismatched denominators. Update
the Reachability summary flow around ReachableFraction and Sentence to use
observed-time wording consistent with the fraction (or change the fraction to
measured-day calculation), preserving the full-window wording behavior; add
coverage for a window containing reachable, unreachable, and unmeasured days.

Comment on lines +63 to +71
public static async Task ApplyMigrationsAsync(IServiceProvider services, ILogger logger)
{
var applied = await new MigrationRunner(
services.GetRequiredService<NpgsqlDataSource>(), logger).ApplyAsync();

if (applied.Count > 0)
{
logger.LogInformation("Applied {Count} migration(s): {Names}", applied.Count, string.Join(", ", applied));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Serialize migrations across application instances.

MigrationRunner.ApplyAsync reads the ledger before it starts each migration transaction. If two instances start against the same database, both can select the same pending migration. One instance can then fail on migration DDL or the unique ledger insert.

Acquire a database-wide advisory lock for the complete migration run. Keep the pending check, migration DDL, and ledger writes under that lock. This prevents a rolling deployment from leaving one web instance unable to start.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/MUI.Web/Data/PostgresData.cs` around lines 63 - 71, Update
MigrationRunner.ApplyAsync to acquire a PostgreSQL database-wide advisory lock
before reading pending migrations and hold it through all migration DDL and
ledger writes, releasing it only after the complete run finishes. Ensure
ApplyMigrationsAsync continues using this serialized migration path and
preserves the existing applied-migration logging.

Comment on lines 184 to +194
/// <summary>A MU* server, to the extent the probe can tell.</summary>
/// <summary>
/// A server that says it is not ready, then pauses for longer than a gap between lines.
/// </summary>
/// <remarks>
/// tbaMUD's shape, measured on <c>tbamud.com:4000</c>: <c>Attempting to Detect Client, Please
/// Wait...</c>, then about a second and a half of silence, then the real screen. Under a plain
/// quiet period the placeholder <em>was</em> the connect screen — it became the stored banner and
/// its hash became the game's identity, so two unrelated tbaMUDs fingerprinted alike and the
/// second went into a duplicate review instead of onto the site.
/// </remarks>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Move the FakeGame class summary back above the class.

Line 184 (/// <summary>A MU* server, to the extent the probe can tell.</summary>) is the doc comment for the FakeGame class. The two new tests were inserted directly after it, so this summary is now immediately followed by another <summary> block (Line 185-194) with no code element in between. That merged comment block attaches to AScreenThatSaidItWasNotReadyIsWaitedFor, not to FakeGame. Move Line 184 to sit directly above private sealed class FakeGame : IAsyncDisposable (Line 239) so each doc comment documents its intended element.

♻️ Proposed fix
-    /// <summary>A MU* server, to the extent the probe can tell.</summary>
     /// <summary>
     /// A server that says it is not ready, then pauses for longer than a gap between lines.
     /// </summary>
@@
     private sealed class FakeGame : IAsyncDisposable
+    /// <summary>A MU* server, to the extent the probe can tell.</summary>
     {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/MUI.Crawl.Tests/ProbeSessionTests.cs` around lines 184 - 194, Move the
“A MU* server...” XML summary from its current position to immediately above the
private sealed FakeGame class declaration, leaving the readiness-test summary
attached to AScreenThatSaidItWasNotReadyIsWaitedFor and preserving both
descriptions.

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