main
Drew Owens 0.9.0: bridge data moved out of the install cache (recovery survives updates), closed-in-place records, gates route thr… 5d02ca9 20d ago
//! ADVERSARIAL REVIEW OF 0.8.4 — pass thirty-three.
//!
//! THE DIALOG TEXT CENSUS IS STRICTLY READ-ONLY, and better than asked for.
//! `dialog_contents` (launchos.rs:1462) is `EnumChildWindows` + `GetClassNameW`
//! + `GetWindowTextW`. There is no UIA anywhere in it, so no `InvokePattern`, no
//! `SetFocus`, no `WM_CLOSE`, and no pattern object that could have a side
//! effect. `GetWindowTextW` against a control in ANOTHER process does not send
//! `WM_GETTEXT` — Windows returns the cached text instead — so not even a
//! message crosses the boundary. It takes `ENUM_LOCK`, clears its own static,
//! and reads. Nothing can be pressed by reading.
//!
//! Truncation is safe: `[u16; 1024]`, sliced by the returned length, through
//! `from_utf16_lossy`, so a cut mid-surrogate yields a replacement character
//! rather than a panic. It is SILENT, though — `DialogContents` has no
//! "truncated" flag, so a long message is reported as if it were the whole
//! message.
//!
//! `acknowledge_only` errs safe on the checkbox case. A Win32 checkbox's class
//! IS "Button", so "Don't show this again" lands in `buttons`, making `named.len()`
//! 2 and the verdict FALSE for a dialog that really is OK-only. It under-claims,
//! which is the right direction. The cost is that the `buttons` array a HUMAN
//! reads to decide what to click lists a checkbox as a button, and the simulator
//! hands back pre-classified lists, so this classification has no coverage at
//! all — the same fixture-fidelity gap as the interconnect route in pass 31.
//!
//! On the quit gate: `identity_mismatch` IS handled and refuses (quit-gate.py:468).

mod common;
use common::*;
use serde_json::json;
use std::path::Path;

fn s(v: &serde_json::Value) -> String {
    serde_json::to_string_pretty(v).unwrap_or_default()
}

/// SEVERITY 1, CONSENT — THE "NEVER ASK" PIN DOES NOT READ THE FILE AN AGENT
/// READS, AND EXCUSES ANY LINE MENTIONING A DIALOG.
///
/// `nothing_asks_the_user_to_close_documents` walks `src/`, `tools/` and
/// `docs/`. `SKILL.md` and `README.md` live at the REPO ROOT and are not
/// scanned. `SKILL.md` is the file an agent loads to learn how to drive this
/// bridge: it is the highest-leverage place in the tree for "ask the user to
/// close" to reappear, and it is the one place the pin cannot see. Both are
/// currently clean — every hit in them states the rule rather than breaking it —
/// so this is a coverage hole, not a live violation, which is exactly when it is
/// cheap to close.
///
/// The second half is worse because it is load-bearing:
///
/// ```ignore
/// let is_a_dialog = l.contains("dialog") || l.contains("answer");
/// if asks && !is_the_rule && !is_a_dialog { … }
/// ```
///
/// Any line containing the word "dialog" is exempt. So
///
///   "a dialog is up, so ask the user to close the documents by hand"
///
/// passes the pin — and the dialog-blocked path is precisely where asking is
/// tempting, because it is the one branch where the bridge genuinely cannot
/// proceed. Drew's session is sitting behind a License Manager dialog right now.
/// The exemption should be for asking them to ANSWER a dialog, not for any
/// sentence that happens to name one.
///
/// (It is also line-granular, while nearly every hint in this codebase is a
/// `\`-continued string spanning several lines. A sentence broken across two
/// lines is invisible to it.)
#[test]
fn the_never_ask_pin_covers_the_files_an_agent_reads() {
    let root = Path::new(env!("CARGO_MANIFEST_DIR"));
    let pin = std::fs::read_to_string(root.join("tests/wiring.rs")).expect("wiring.rs");
    let body = pin
        .split("fn nothing_asks_the_user_to_close_documents")
        .nth(1)
        .expect("the pin exists")
        .to_string();

    // The files an agent actually loads to learn this bridge.
    assert!(
        root.join("SKILL.md").exists() && root.join("README.md").exists(),
        "precondition: SKILL.md and README.md are at the repo root"
    );
    let scans_root_docs = body.contains("SKILL.md")
        || body.contains("root.join(\"README.md\")")
        || body.contains("read_dir(root)")
        || body.contains("stack = vec![root.to_path_buf()");

    // THE EXEMPTION, TESTED AS A PROPERTY — and by 0.8.6 there is no exemption
    // block left to read. Pass 35 replaced the phrase exemptions with ONE
    // positional rule (`asks_to_close`: the close must FOLLOW the ask, inside the
    // same request) pinned by a table of example sentences. So this check is now
    // the strongest available: does that table carry THIS finding's own sentence
    // as a must-CATCH case?
    //
    // "A dialog is up, so ask the user to close the documents by hand." is the
    // sentence this test was written about. Present in the table expecting
    // `true`, the word-based excuse is provably gone — a shape check could only
    // ever have suggested that.
    let planted = "ask the user to close the documents by hand";
    let word_based_dialog_excuse = !pin
        .split(planted)
        .nth(1)
        .map(|after| after.chars().take(24).collect::<String>().contains("true"))
        .unwrap_or(false);

    assert!(
        scans_root_docs && !word_based_dialog_excuse,
        "the pin walks src/, tools/ and docs/ only, so SKILL.md and README.md — the files an \
         AGENT reads to learn how to drive this bridge — are outside it; and it exempts ANY line \
         containing the word \"dialog\", so \"a dialog is up, so ask the user to close the \
         documents by hand\" passes. That is the one branch where asking is tempting, and it is \
         where Drew's session is sitting right now. Exempt asking them to ANSWER a dialog, not \
         every sentence that names one. (scans root docs: {scans_root_docs}, word-based dialog \
         excuse: {word_based_dialog_excuse})"
    );
}

/// SEVERITY 1 — THE POST-SWEEP RE-MEASURE DROPS TWO OF THE THREE CONDITIONS THE
/// GATE'S OWN PREAMBLE REQUIRES.
///
/// `tools/quit-gate.py` states its first claim at the top of the file:
///
/// > 1. ZERO DOCS  documentCount == 0 AND unsavedDocuments == 0 AND the count
/// >               AGREES with the enumerated list (a truncated enumeration is …)
///
/// Before the sweep it checks all three. After the sweep it checks one:
///
/// ```python
/// state = verb("swx_get_app_state", {})
/// count = state.get("documentCount")
/// unsaved = state.get("unsavedDocuments")
/// if count != 0:
///     raise Refused(…)
/// ```
///
/// `unsaved` is reassigned and never looked at again. `documentsTruncated` is
/// not re-read. And `listed` — the enumeration the count is supposed to agree
/// with — is never re-fetched, so the later `len(listed) != 0` cross-check is
/// comparing the post-sweep count against the PRE-sweep list. A session that
/// reports 0 documents from a truncated enumeration, or 0 documents with a
/// non-zero unsaved count, proceeds to the quit ladder.
///
/// The sweep is exactly the operation that makes a stale enumeration likely: it
/// closes documents, so the list it was measured against is guaranteed out of
/// date. The fix is to re-run the same three-condition check rather than a
/// weaker one — the function to call already exists a hundred lines above.
#[test]
fn the_quit_gate_re_measures_everything_it_measured_the_first_time() {
    let root = Path::new(env!("CARGO_MANIFEST_DIR"));
    let src = std::fs::read_to_string(root.join("tools/quit-gate.py")).expect("quit-gate.py");
    let after = src
        .split("# RE-MEASURE.")
        .nth(1)
        .expect("the re-measure exists")
        .split("# A DRAIN THAT MATCHED NOTHING")
        .next()
        .unwrap()
        .to_string();

    let rechecks_unsaved = after.contains("unsaved != 0") || after.contains("unsaved");
    let rechecks_truncation = after.contains("truncated") || after.contains("Truncated");
    let refetches_listing = after.contains("docs(") || after.contains("swx_docs");

    assert!(
        rechecks_truncation && refetches_listing,
        "the post-sweep re-measure checks documentCount only. The gate's own preamble requires \
         THREE things — count 0, unsaved 0, and the count agreeing with the enumeration, with a \
         truncated enumeration disqualifying — and the re-measure re-reads `unsavedDocuments` \
         into a variable it never tests, never re-reads `documentsTruncated`, and never re-fetches \
         the listing, so the later cross-check compares a post-sweep count against a PRE-sweep \
         list. The sweep is the very thing that invalidates that list. \
         (unsaved: {rechecks_unsaved}, truncation: {rechecks_truncation}, refetch: \
         {refetches_listing})\n--- re-measure block ---\n{after}"
    );
}

/// SEVERITY 2 — THE QUIT LADDER REPORTS A DIALOG WITHOUT READING WHAT IT SAYS.
///
/// `read_contents` has exactly one caller in the tree: `blocking_dialog_now`
/// (verbs3.rs:658), which serves the SWEEP and the import. The quit ladder builds
/// its dialog at verbs.rs:1136 and :1543 and serialises it at :1184 with
/// `to_json()` — never calling `read_contents` — so `text`, `buttons` and
/// `acknowledgeOnly` come back null and `contentsReadable` false.
///
/// That is the defect 0.8.4 fixed for the sweep, in the other half of the same
/// release. The commit's own words: a title alone cannot tell "your licence
/// expired" from "the licence server is unreachable and the session ends after
/// five more tries". The quit ladder is where a stuck session is diagnosed, and
/// it reports the title alone.
#[test]
fn a_quit_that_stops_on_a_dialog_says_what_the_dialog_said() {
    let machine = TempDir::new("adv32-quit-dialog-machine");
    std::fs::write(
        machine.join("dialog-open.json"),
        json!({
            "hwnd": 9001, "className": "#32770", "title": "License Manager", "visible": true,
            "text": ["Failed to check 1 license(s): 3DEXPERIENCE SOLIDWORKS Professional: \
                      Unable to connect to server. The session will end after the next 5 \
                      unsuccessful attempts."],
            "buttons": ["OK"],
        })
        .to_string(),
    )
    .unwrap();
    let scratch = TempDir::new("adv32-quit-dialog");
    let b = Bridge::start_in(
        TempDir::borrowed(&scratch.0),
        &[("SWX_LAUNCH_FAKE_DIR", &machine.path())],
    );
    b.call("swx_start", json!({}));

    let r = b.call("swx_stop", json!({"reason": "adversary: a dialog is up"}));
    let text = s(&r);
    assert!(
        text.contains("License Manager"),
        "precondition: the quit reports the dialog at all: {text}"
    );

    assert!(
        text.contains("unsuccessful attempts") || text.contains("\"contentsReadable\": true"),
        "the quit reports the dialog by TITLE and nothing else — read_contents is never called \
         on this path, so text, buttons and acknowledgeOnly are null and contentsReadable is \
         false. 0.8.4 fixed exactly this for the sweep, in the same release, for the reason its \
         own commit gives: a title cannot tell \"your licence expired\" from \"the licence server \
         is unreachable and the session ends after five more tries\". The quit ladder is where a \
         stuck session gets diagnosed. Reply: {text}"
    );
}

// ── PASS 34: tools/drain-gate.py --fixture new, the twin measurement ─────────
//
// SOURCE-SCAN TESTS. This gate drives the live desktop — it opens and closes
// documents in Drew's session — so I read it and do not run it. Every claim
// below is a structural property of the file, cited by line.

fn gate_src() -> String {
    std::fs::read_to_string(
        Path::new(env!("CARGO_MANIFEST_DIR")).join("tools/drain-gate.py"),
    )
    .expect("tools/drain-gate.py")
}

/// SEVERITY 1 — CLEANUP IS NOT ON A FINALLY, SO A REFUSAL LEAVES EVERYTHING.
///
/// `STEP 6 — CLEANUP` lives inside `main()` behind `if not a.keep:`. Every
/// `Refused` in this file propagates to the `__main__` handler, which prints and
/// `sys.exit(2)` — main's cleanup is never reached. The same is true of any
/// unhandled exception, and of a `KeyboardInterrupt` on a gate that makes
/// minutes-long PowerShell calls.
///
/// This is not hypothetical: it is the mechanism behind the leftovers seen live.
/// The gate REFUSES often and by design — a dialog on screen, a truncated
/// enumeration, `interconnect_blocks_import`, an identity mismatch — and on this
/// machine right now it refuses every run, because a License Manager dialog is
/// up. So the litter accumulates on exactly the runs that are working correctly.
///
/// Worse, the cleanup also CLOSES the documents the gate opened. Skipping it
/// does not merely leave files: it leaves the gate's own documents open in the
/// user's SOLIDWORKS, which is the state the next run then has to reason about.
///
/// A `try/finally` around STEP 6, or an `atexit` registered the moment the
/// scratch directory is made, costs a line and removes the whole class.
#[test]
fn the_drain_gate_cleans_up_even_when_it_refuses() {
    let src = gate_src();
    let cleanup = src.find("STEP 6 — CLEANUP").expect("the cleanup step exists");
    let head = &src[..cleanup];
    // Either a finally/atexit guards it, or it is unreachable on the refusal path.
    let guarded = head.contains("finally:") || src.contains("atexit.register");
    assert!(
        guarded,
        "STEP 6 — CLEANUP sits inside main() behind `if not a.keep:`, and every Refused in this \
         file is caught at __main__ with sys.exit(2), so the cleanup never runs on a refusal — \
         nor on a crash or a Ctrl-C. The gate refuses BY DESIGN (a dialog on screen, a truncated \
         enumeration, an identity mismatch) and on this machine it refuses every run right now, \
         so the litter accumulates on exactly the runs that are behaving correctly. It is not \
         only files: STEP 6 also CLOSES the documents this gate opened, so a refusal leaves them \
         open in the user's session. try/finally, or atexit at the moment the scratch dir is made."
    );
}

/// SEVERITY 1 — THE GATE CALLS ITS OWN FILES FOREIGN.
///
/// ```python
/// wrote = [w for w in [FIXTURE_STEM + ".step"] + ([twin_name] if twin_open else [])
///          if str(w or "").strip()]
/// ```
///
/// `twin_name` is assigned in the `twin_open` branch (line 387) — so when the
/// probe is REFUSED, which is the measured behaviour on this edition
/// (`swFileWithSameTitleAlreadyOpen = 65536`), the copy the gate itself made at
/// `twin/<base>` is not in `wrote`. It is not removed, `twin_dir` is therefore
/// not empty and is not removed, and the file then appears in `left` under:
///
/// > "%TEMP%\swx-drain-gate still holds files this gate did not write, so the
/// > directory was left alone"
///
/// The gate wrote it. It wrote it four lines after deciding to. And because the
/// scratch directory is a FIXED path — `%TEMP%\swx-drain-gate`, not a unique one
/// per run — the same message also reports files left by earlier crashed runs of
/// this same gate as foreign. That was the live observation: `twin`,
/// `Part5-…`, `Part7-…`.
///
/// The claim matters because it is what stops the cleanup: the gate declines to
/// remove a directory whose contents it believes somebody else wrote, which is
/// the right instinct applied to a wrong fact. Two fixes, either sufficient:
/// write a `manifest.json` of what this run created and clean by it, or make the
/// scratch directory unique per run so anything in it is by definition this
/// run's.
#[test]
fn the_drain_gate_knows_which_files_are_its_own() {
    let src = gate_src();
    // BUILDER NOTE, 0.8.5: the `wrote` list is GONE — replaced by a per-run
    // manifest, which is the third of the three fixes this test offers. So its
    // absence is not a missing precondition, it is the fix; the detector treats
    // it that way rather than panicking on a shape that no longer exists.
    let wrote = src
        .find("wrote = [w for w in")
        .map(|i| src[i..].lines().take(2).collect::<Vec<_>>().join(" "))
        .unwrap_or_else(|| "(no `wrote` list: replaced by a per-run manifest)".to_string());

    let twin_is_conditional = wrote.contains("if twin_open");
    let has_manifest = src.contains("manifest");
    let unique_scratch = src.contains("mkdtemp") || src.contains("swx-drain-gate-%");

    // Any ONE of these closes it: attribute the twin unconditionally, keep a
    // manifest of what this run wrote, or make the scratch dir unique per run.
    assert!(
        !twin_is_conditional || has_manifest || unique_scratch,
        "the twin copy is attributed to this gate only `if twin_open`, so the REFUSED probe — the \
         measured behaviour on this edition, swFileWithSameTitleAlreadyOpen = 65536 — leaves the \
         file the gate copied itself, then reports it as one \"this gate did not write\". The \
         scratch directory is a fixed %TEMP%\\swx-drain-gate rather than unique per run, so the \
         same message also calls earlier crashed runs' files foreign (seen live: twin, Part5-…, \
         Part7-…). That wrong fact is what STOPS the cleanup — the gate declines to touch a \
         directory it thinks is someone else's. Record a manifest of what this run wrote, or make \
         the scratch directory unique per run. (twin conditional: {twin_is_conditional}, \
         manifest: {has_manifest}, unique scratch: {unique_scratch})\n  wrote = {wrote}"
    );
}

/// SEVERITY 2 — THE MEASUREMENT IS LABELLED "MEASURED" AND KEPT NOWHERE.
///
/// The 65536 result goes into `NOTES`, which is printed to stdout at the end of
/// the run and then lost. There is no date on it and no record of the invocation
/// that produced it — the note names the error code and message, but not that it
/// was `swx_open {path: <twin dir>/<basename>}` after copying a document's own
/// file to a second directory, which is the only thing that makes the result
/// reproducible.
///
/// Everywhere else in this project a measurement is written down where the code
/// that depends on it lives, with its date: `com.rs` carries "measured live
/// 2026-09-17: TRUE" beside the toggle id, and `disk.rs` carries the size table.
/// That is why those facts survived three releases of refactoring. This one has
/// to be re-derived from a terminal scrollback.
///
/// It also decides something structural: if SAVED documents cannot collide, then
/// the drain's title-collision guard only ever protects PATHLESS documents — the
/// note says exactly that, and it is a claim about which code paths are
/// reachable. A claim of that weight belongs next to the guard.
///
/// (The honesty of the branches is fine and worth saying: a third error code is
/// recorded as "UNMEASURED this run" rather than folded into either answer, and
/// a success is recorded as the affirmative measurement. The cleanup closes
/// by PATH, and only paths the gate created — `temp_paths` plus its own `dup`.)
#[test]
fn the_same_title_measurement_is_written_down_where_it_is_used() {
    let src = gate_src();
    let note = src
        .split("MEASURED: SOLIDWORKS REFUSED")
        .nth(1)
        .map(|s| s.chars().take(700).collect::<String>())
        .expect("the refusal note exists");

    let has_date = note.contains("2026-") || note.contains("%s-%s-%s") || note.contains("date");
    let has_invocation = note.contains("swx_open") || note.contains("invocation");

    assert!(
        has_date && has_invocation,
        "the 65536 result is labelled MEASURED and carries neither the DATE it was measured nor \
         the INVOCATION that produced it, and it lives in NOTES — printed at the end of a run and \
         then gone. Every other measurement in this project is written beside the code that \
         depends on it, dated: com.rs has \"measured live 2026-09-17: TRUE\" next to the toggle \
         id, disk.rs has the size table. This one says something structural — that a saved-document \
         title collision is IMPOSSIBLE on this edition, so the drain's collision guard only ever \
         protects pathless documents — and a claim of that weight belongs next to the guard. \
         (date: {has_date}, invocation: {has_invocation})"
    );
}